mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -13,7 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
|
||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
@@ -95,7 +95,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
BookingOrdersModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||
import { Contract } from '../modules/contracts/entities/contract.entity';
|
||||
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||||
import {
|
||||
ContractSignature,
|
||||
ContractSignerRole,
|
||||
} from '../modules/contracts/entities/contract-signature.entity';
|
||||
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { getTemplateMeta } from './contract-template.registry';
|
||||
import { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
/**
|
||||
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||
* `ContractSignatureView` but widens `role` to the contract's signer roles
|
||||
* (CUSTOMER | STAFF | DIRECTOR | CEO).
|
||||
*/
|
||||
export interface ContractDocumentSignatureView {
|
||||
role: ContractSignerRole;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||||
export interface ContractUnitRateRow {
|
||||
label: string;
|
||||
unitPrice: number;
|
||||
unit: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing schedule for a Contract document: a unit-rate schedule (one price per
|
||||
* unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
|
||||
* to stay structurally compatible with the renderer's expectations of
|
||||
* {@link ContractViewModel.pricing} (it reads `currency`).
|
||||
*/
|
||||
export interface ContractUnitRateSchedule {
|
||||
displayMode: 'UNIT_RATES';
|
||||
unitRates: ContractUnitRateRow[];
|
||||
currency: string;
|
||||
equipmentReturn?: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
}
|
||||
|
||||
/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */
|
||||
function unitLabel(unit: string): string {
|
||||
switch (unit) {
|
||||
case 'per_container':
|
||||
return 'container';
|
||||
case 'per_ton':
|
||||
return 'ton';
|
||||
case 'per_item':
|
||||
return 'item';
|
||||
case 'per_km':
|
||||
return 'km';
|
||||
default:
|
||||
return 'unit';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the contract PDF view-model from the {@link Contract} aggregate (the new
|
||||
* source of truth) — mirrors {@link ContractViewModelBuilder} but every field is
|
||||
* sourced from the contract, its routes, cargo scope and unit-rate breakdown.
|
||||
* The legacy booking-based builder remains untouched for the migration window.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractDocumentViewModelBuilder {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
) {}
|
||||
|
||||
async build(
|
||||
contractId: string,
|
||||
): Promise<{ contract: Contract; view: ContractViewModel }> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
}
|
||||
|
||||
const templateKey =
|
||||
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||||
const template = getTemplateMeta(templateKey);
|
||||
const pricing = this.buildPricing(contract);
|
||||
const signatures = await this.loadSignatures(contractId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
const hasContractFile = Boolean(
|
||||
contract.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
|
||||
const view: ContractViewModel = {
|
||||
bookingId: contract.id,
|
||||
reference: contract.reference,
|
||||
status: contract.status,
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
client: {
|
||||
companyName: contract.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(contract.company?.address),
|
||||
companyLocation: this.valueOrDash(contract.company?.country),
|
||||
phone: this.valueOrDash(contract.company?.phone),
|
||||
email: this.valueOrDash(contract.company?.email),
|
||||
tinNumber: this.valueOrDash(contract.company?.tin),
|
||||
vatNumber: this.valueOrDash(contract.company?.vatNumber),
|
||||
fanNumber: this.valueOrDash(contract.company?.fanNumber),
|
||||
businessLicense: this.valueOrDash(
|
||||
contract.company?.companyProfiles?.[0]?.businessLicense,
|
||||
),
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: this.buildSchedule(contract),
|
||||
pricing: pricing as unknown as ContractViewModel['pricing'],
|
||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||
// view-model's narrower CUSTOMER|STAFF role union.
|
||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignStaff:
|
||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
};
|
||||
|
||||
return { contract, view };
|
||||
}
|
||||
|
||||
private async loadSignatures(
|
||||
contractId: string,
|
||||
): Promise<ContractDocumentSignatureView[]> {
|
||||
const rows = await this.contractsRepository.findSignatures(contractId);
|
||||
return rows.map((s) => this.toSignatureView(s));
|
||||
}
|
||||
|
||||
toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
|
||||
return {
|
||||
role: row.role,
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */
|
||||
private buildPricing(contract: Contract): ContractUnitRateSchedule {
|
||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||
const currency = breakdown?.currency ?? contract.paymentCurrency;
|
||||
const lineItems = breakdown?.lineItems ?? [];
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
unitRates: lineItems.map((line) => ({
|
||||
label: line.label,
|
||||
unitPrice: line.unitPrice,
|
||||
unit: unitLabel(line.unit),
|
||||
currency,
|
||||
})),
|
||||
currency,
|
||||
equipmentReturn: contract.equipmentReturn ?? '—',
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||
const cargoName =
|
||||
cargoScope?.cargoType?.cargoTypeName ||
|
||||
cargoScope?.cargoFreeText ||
|
||||
(cargoScope?.containerSize
|
||||
? `${cargoScope.containerSize} container`
|
||||
: 'Container cargo');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
tradeDirection: this.valueOrDash(contract.tradeDirection),
|
||||
freightType: this.valueOrDash(contract.freightType),
|
||||
serviceType: this.valueOrDash(
|
||||
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||||
),
|
||||
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||
};
|
||||
}
|
||||
|
||||
/** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
|
||||
private firstRoute(contract: Contract): ContractRoute | undefined {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
return routes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The template resolver reads a Booking; a contract carries equivalent fields
|
||||
* under a different shape (cargoType lives on cargoScope). Build a minimal,
|
||||
* structurally-compatible adapter rather than widening the resolver signature.
|
||||
*/
|
||||
private toResolverInput(
|
||||
contract: Contract,
|
||||
): Parameters<ContractTemplateResolver['resolve']>[0] {
|
||||
const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
|
||||
return {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
cargoType: cargoType ?? undefined,
|
||||
serviceType: contract.serviceType,
|
||||
} as Parameters<ContractTemplateResolver['resolve']>[0];
|
||||
}
|
||||
|
||||
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||
}
|
||||
|
||||
private formatDate(value?: Date | string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
private valueOrDash(value?: string | number | null): string {
|
||||
if (value === undefined || value === null || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for callers that want the role union without importing the entity.
|
||||
export type { ContractSignerRole };
|
||||
@@ -25,6 +25,26 @@
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if pricing.unitRates}}
|
||||
<h3>Unit Rate Schedule</h3>
|
||||
<p>
|
||||
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
|
||||
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Unit price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.unitRates}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
@@ -56,6 +76,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
<h3>Terms of payment</h3>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Contract–Booking separation (additive phase). Introduces a first-class
|
||||
* `freight.contracts` aggregate that owns the legal/commercial agreement (scope
|
||||
* + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
|
||||
*
|
||||
* Purely additive: no legacy columns are dropped here. The data backfill and
|
||||
* legacy-column removal happen in a later cutover migration.
|
||||
*
|
||||
* See docs/new-doc.md §5.
|
||||
*/
|
||||
export class CreateContracts1822000000000 implements MigrationInterface {
|
||||
name = 'CreateContracts1822000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── contracts ───────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contracts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||
|
||||
company_id UUID,
|
||||
company_profile_id UUID,
|
||||
is_government BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
government_institution VARCHAR(255),
|
||||
|
||||
contract_kind VARCHAR(20) NOT NULL,
|
||||
renewal_of_id UUID REFERENCES freight.contracts(id),
|
||||
trade_direction VARCHAR(10) NOT NULL,
|
||||
freight_type VARCHAR(20) NOT NULL,
|
||||
|
||||
service_type_id UUID NOT NULL,
|
||||
payment_currency VARCHAR(5) NOT NULL,
|
||||
customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
customs_clearing_agent VARCHAR(200),
|
||||
equipment_return VARCHAR(20),
|
||||
|
||||
first_mile_pickup_address TEXT,
|
||||
first_mile_pickup_lat NUMERIC(10,7),
|
||||
first_mile_pickup_lng NUMERIC(10,7),
|
||||
last_mile_delivery_address TEXT,
|
||||
last_mile_delivery_lat NUMERIC(10,7),
|
||||
last_mile_delivery_lng NUMERIC(10,7),
|
||||
|
||||
is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
estimated_shipment_date TIMESTAMPTZ,
|
||||
contract_validity_days INT,
|
||||
contract_valid_from TIMESTAMPTZ,
|
||||
contract_valid_until TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||
clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
clearance_cycle_number INT NOT NULL DEFAULT 0,
|
||||
|
||||
pricing_breakdown JSONB,
|
||||
pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
|
||||
|
||||
contract_type VARCHAR(20),
|
||||
contract_template_key VARCHAR(128),
|
||||
contract_generated_at TIMESTAMPTZ,
|
||||
contract_summary TEXT,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
financial_terms JSONB,
|
||||
|
||||
approved_by_staff_id UUID,
|
||||
approved_by_staff_at TIMESTAMPTZ,
|
||||
signed_by_director_id UUID,
|
||||
signed_by_director_at TIMESTAMPTZ,
|
||||
signed_by_ceo_id UUID,
|
||||
signed_by_ceo_at TIMESTAMPTZ,
|
||||
customer_signed_at TIMESTAMPTZ,
|
||||
fully_executed_at TIMESTAMPTZ,
|
||||
locked_at TIMESTAMPTZ,
|
||||
|
||||
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_contracts_company ON freight.contracts(company_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
|
||||
|
||||
// ── contract_routes ──────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
origin_yard_id UUID NOT NULL,
|
||||
destination_yard_id UUID NOT NULL,
|
||||
km NUMERIC(10,2),
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
|
||||
|
||||
// ── contract_cargo_scope ─────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
container_size VARCHAR(10),
|
||||
cargo_type_id UUID,
|
||||
cargo_free_text VARCHAR(200),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
|
||||
|
||||
// ── contract_signatures ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
signer_display_name VARCHAR(255) NOT NULL,
|
||||
signature_file_id UUID,
|
||||
consent_text TEXT,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
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_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
|
||||
|
||||
// ── contract_approval_steps ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
step_order SMALLINT NOT NULL DEFAULT 0,
|
||||
required_role VARCHAR(40) NOT NULL,
|
||||
blocks_role VARCHAR(40),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
acted_by_staff_id UUID,
|
||||
acted_at TIMESTAMPTZ,
|
||||
note TEXT,
|
||||
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_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
|
||||
|
||||
// ── contract_rate_snapshots ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
rate_id UUID,
|
||||
rate_code VARCHAR(64) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
unit_price NUMERIC(14,2) NOT NULL,
|
||||
unit_of_measure VARCHAR(32) NOT NULL,
|
||||
currency VARCHAR(5) NOT NULL,
|
||||
container_size VARCHAR(10),
|
||||
is_surcharge BOOLEAN DEFAULT FALSE,
|
||||
conditional_on VARCHAR(32),
|
||||
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_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
|
||||
|
||||
// ── contract_review_notes ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
note_type VARCHAR(40) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_role VARCHAR(20),
|
||||
author_user_id UUID,
|
||||
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_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
|
||||
|
||||
// ── contract_clearance_cycles ────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
cycle_number INT NOT NULL,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
|
||||
booking_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
clearance_ready_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
|
||||
|
||||
// ── contract_document_review (pre-booking clearance, Path B) ─────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_document_review (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
setting_code VARCHAR(128) NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_record_id UUID,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
note TEXT,
|
||||
uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
|
||||
reviewed_by_staff_id UUID,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_document_review_doc
|
||||
UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
|
||||
|
||||
// ── clearance_milestones (GL tracking) ───────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
milestone_code VARCHAR(64) NOT NULL,
|
||||
milestone_label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
owner_region VARCHAR(5),
|
||||
triggered_by_doc BOOLEAN DEFAULT FALSE,
|
||||
triggered_at TIMESTAMPTZ,
|
||||
triggered_by_user_id UUID,
|
||||
note TEXT,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
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_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
|
||||
// booking-scoped and contract-cycle-scoped uniqueness for milestone codes
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
|
||||
ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
|
||||
ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// ── booking_container_units (per-unit container detail) ──────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_container_units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
|
||||
container_number VARCHAR(64) NOT NULL,
|
||||
seal_number VARCHAR(64),
|
||||
vgm_tons NUMERIC(10,3) NOT NULL,
|
||||
is_hazardous BOOLEAN DEFAULT FALSE,
|
||||
is_reefer BOOLEAN DEFAULT FALSE,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
|
||||
);
|
||||
`);
|
||||
|
||||
// ── ALTER bookings ───────────────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
|
||||
// One active booking per ONE_TIME contract. Postgres forbids a subquery in an
|
||||
// index predicate, so we denormalize the contract kind onto the booking and
|
||||
// predicate on that. The column is stamped at booking creation from the
|
||||
// contract; the app layer (ContractBookingService) is the primary guard and
|
||||
// this index is the backstop.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
|
||||
ON freight.bookings (contract_id)
|
||||
WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
|
||||
AND contract_id IS NOT NULL
|
||||
AND contract_kind = 'ONE_TIME';
|
||||
`);
|
||||
|
||||
// ── ALTER booking_container ──────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
|
||||
|
||||
// ── ALTER booking_document_review (denormalized contract link) ───────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
|
||||
// ── Extend file_upload_fields with phased GL metadata ────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Data backfill for the contract–booking separation (docs/new-doc.md §17).
|
||||
*
|
||||
* For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
|
||||
* `freight.contracts` row from its contract-phase columns, copy its routes
|
||||
* (contract_route_lines → contract_routes, dropping quantity), and point the
|
||||
* contract + every child shipment booking (linked via booking_orders) at it.
|
||||
*
|
||||
* Per §19 item 1, historical ONE_TIME bookings that went through the full
|
||||
* contract flow get a contract parent inserted and `contract_id` set on the same
|
||||
* booking row (no row split).
|
||||
*
|
||||
* Idempotent: skips bookings that already have `contract_id` set, and matches a
|
||||
* synthesised contract by a deterministic `CTR-<bookingId>` reference.
|
||||
*/
|
||||
export class BackfillContractsFromBookings1823000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'BackfillContractsFromBookings1823000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
|
||||
// columns. Reference is derived from the source booking id so re-runs are
|
||||
// idempotent (ON CONFLICT DO NOTHING on the unique reference).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
|
||||
CASE
|
||||
WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
|
||||
WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
|
||||
WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
|
||||
WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
|
||||
WHEN b.status IN ('REJECTED') THEN 'REJECTED'
|
||||
ELSE 'CONTRACT_ACTIVE'
|
||||
END,
|
||||
CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
|
||||
0,
|
||||
b.pricing_breakdown,
|
||||
b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE b.booking_type = 'GENERAL_CONTRACT'
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Copy each general contract's route lines into contract_routes (no qty).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
|
||||
SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
|
||||
FROM freight.contract_route_lines crl
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Point the general-contract booking itself at its new contract, and stamp
|
||||
// the denormalized contract_kind for the active-booking index.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND b.booking_type = 'GENERAL_CONTRACT'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 4. Point each child shipment booking (spawned via booking_orders) at the
|
||||
// same contract as its parent general contract.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings child
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.booking_orders bo
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
|
||||
WHERE child.id = bo.booking_id
|
||||
AND child.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
|
||||
// a contract parent and point the same booking row at it (no row split).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
|
||||
'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
|
||||
b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL
|
||||
AND b.contract_generated_at IS NOT NULL
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 6. Build a single route per ONE_TIME contract from the booking's own
|
||||
// origin/destination (general contracts already got their routes in step 2).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
|
||||
SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
|
||||
WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Unlink bookings and drop the synthesised contracts (and their cascaded routes).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
|
||||
WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Cutover cleanup (docs/new-doc.md §17 Phase 4). Runs AFTER the backfill
|
||||
* (1823…) so every legacy general contract + drawdown already lives in the
|
||||
* `contracts` aggregate.
|
||||
*
|
||||
* Drops the now-unused booking-as-contract artifacts:
|
||||
* - `bookings.booking_type` (every booking is a real shipment now)
|
||||
* - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
|
||||
* - the `booking_orders` / `booking_order_lines` drawdown ledger
|
||||
* - `contract_route_lines` (superseded by `contract_routes`)
|
||||
*
|
||||
* The shipment/payment/scheduling/allocation columns on `bookings` are kept —
|
||||
* the operational pipeline is unchanged.
|
||||
*/
|
||||
export class DropLegacyContractTables1824000000000 implements MigrationInterface {
|
||||
name = 'DropLegacyContractTables1824000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// booking_order_lines references booking_orders → drop child first.
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Re-add the dropped columns (data is not restored — this is a one-way cutover).
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
|
||||
);
|
||||
// The legacy ledger/route tables are intentionally NOT recreated here; restore
|
||||
// from a backup if a rollback past the cutover is ever required.
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -240,23 +234,9 @@ export class BookingContractService {
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from '../booking-orders/road.util';
|
||||
import { isRoadService } from './road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface BookingListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -693,11 +692,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.booking_type = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
GoneException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -27,7 +28,6 @@ import { DataSource, In } from 'typeorm';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
@@ -283,6 +283,15 @@ export class BookingsService {
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Contract–booking separation: contracts are no longer created through the
|
||||
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
|
||||
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
|
||||
if (dto.bookingType === 'GENERAL_CONTRACT') {
|
||||
throw new GoneException(
|
||||
'General contracts are no longer created here. Use POST /contracts instead.',
|
||||
);
|
||||
}
|
||||
|
||||
// let customerId = dto.customerId;
|
||||
// if (!customerId) {
|
||||
// if (!userId) {
|
||||
@@ -295,7 +304,6 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
@@ -442,7 +450,6 @@ export class BookingsService {
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||
@@ -469,7 +476,6 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
@@ -496,27 +502,6 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
||||
// total / container quantities). Each drawdown order picks one lane for
|
||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
dto.routes.map((r) =>
|
||||
routeRepo.create({
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId: null,
|
||||
quantity: 0,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||
@@ -808,7 +793,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
@@ -1017,7 +1001,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
|
||||
/**
|
||||
* One physical container under a booking_container line — its number, seal, and
|
||||
* per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET
|
||||
* in Path B). See §5.10.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_container_units' })
|
||||
@Index(['bookingContainerId'])
|
||||
export class BookingContainerUnit extends BaseEntity {
|
||||
@Column({ name: 'booking_container_id', type: 'uuid' })
|
||||
bookingContainerId!: string;
|
||||
|
||||
@ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_container_id' })
|
||||
bookingContainer?: BookingContainer;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||
containerNumber!: string;
|
||||
|
||||
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
|
||||
sealNumber?: string | null;
|
||||
|
||||
@Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmTons!: number;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
/** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'smallint' })
|
||||
quantity!: number;
|
||||
|
||||
/** How many units of this line are hazardous (≤ quantity). */
|
||||
@Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 })
|
||||
hazardousQuantity!: number;
|
||||
|
||||
/** How many units of this line are refrigerated (≤ quantity). */
|
||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||
reeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
|
||||
@@ -144,13 +144,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
/**
|
||||
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
||||
* umbrella contract that is signed/paid once and then drawn down by many
|
||||
* orders (each order spawns its own ONE_TIME child booking).
|
||||
*/
|
||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||
bookingType!: string;
|
||||
/** The contract this shipment booking was created under (contract–booking split). */
|
||||
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
|
||||
contractId?: string | null;
|
||||
|
||||
/** The contract route (lane) this shipment uses. */
|
||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||
contractRouteId?: string | null;
|
||||
|
||||
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||
contractKind?: string | null;
|
||||
|
||||
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||
createdByRole?: string | null;
|
||||
|
||||
@Column({ name: 'created_by_user_id', type: 'uuid', nullable: true })
|
||||
createdByUserId?: string | null;
|
||||
|
||||
/**
|
||||
* Nullable: general contracts have no shipment date at creation — the date is
|
||||
@@ -220,13 +231,6 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
|
||||
previousContractId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'previous_contract_id' })
|
||||
previousContract?: Booking | null;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IMPORT_MILESTONES, EXPORT_MILESTONES } from '@edr/types';
|
||||
import type { MilestoneOwnerRegion } from './entities/clearance-milestone.entity';
|
||||
|
||||
/**
|
||||
* Static catalog of clearance milestones per trade direction (doc §11.3, §12.2).
|
||||
* Drives the rows seeded onto a contract clearance cycle (pre-booking) and
|
||||
* booking (post-booking). `phaseBoundaryAfter` marks the last pre-booking
|
||||
* milestone — everything after it tracks on the booking.
|
||||
*/
|
||||
export interface MilestoneDef {
|
||||
code: string;
|
||||
label: string;
|
||||
ownerRegion: MilestoneOwnerRegion;
|
||||
triggeredByDoc: boolean;
|
||||
}
|
||||
|
||||
const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
DEPARTED_FROM_DJIBOUTI: { label: 'Departed from Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
ARRIVED_ETHIOPIA: { label: 'Arrived at Port in Ethiopia', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
EXIT_NOTE_GENERATED: { label: 'Exit Note Generated', ownerRegion: 'OPS', triggeredByDoc: true },
|
||||
};
|
||||
|
||||
const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
EXPORT_DOCS_UPLOADED: { label: 'Export Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RELEASE_ORDER_SECURED: { label: 'Release Order Secured', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
EXPORT_RELEASED: { label: 'Export Released', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
WAGON_REQUESTED: { label: 'Wagon Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Codes BEFORE (and including) this one are pre-booking — they attach to the
|
||||
* contract clearance cycle. From the next code onward, milestones attach to the
|
||||
* booking GL creates. Per doc §11.3 the booking is created right after DO_COLLECTED
|
||||
* (import) / EXPORT_RELEASED (export), i.e. before WAGON_REQUESTED.
|
||||
*/
|
||||
const IMPORT_PRE_BOOKING_LAST = 'DO_COLLECTED';
|
||||
const EXPORT_PRE_BOOKING_LAST = 'EXPORT_RELEASED';
|
||||
|
||||
function buildDefs(
|
||||
codes: readonly string[],
|
||||
defs: Record<string, Omit<MilestoneDef, 'code'>>,
|
||||
): MilestoneDef[] {
|
||||
return codes.map((code) => ({ code, ...defs[code] }));
|
||||
}
|
||||
|
||||
export function milestonesForDirection(tradeDirection: string): MilestoneDef[] {
|
||||
if (tradeDirection === 'IMPORT') return buildDefs(IMPORT_MILESTONES, IMPORT_DEFS);
|
||||
if (tradeDirection === 'EXPORT') return buildDefs(EXPORT_MILESTONES, EXPORT_DEFS);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Split the milestone list into pre-booking (contract) and post-booking (booking). */
|
||||
export function splitMilestones(tradeDirection: string): {
|
||||
preBooking: MilestoneDef[];
|
||||
postBooking: MilestoneDef[];
|
||||
} {
|
||||
const all = milestonesForDirection(tradeDirection);
|
||||
const boundary =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_PRE_BOOKING_LAST : EXPORT_PRE_BOOKING_LAST;
|
||||
const idx = all.findIndex((m) => m.code === boundary);
|
||||
if (idx < 0) return { preBooking: all, postBooking: [] };
|
||||
return { preBooking: all.slice(0, idx + 1), postBooking: all.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** The handoff milestone that flips primary ownership ET ↔ DJ (doc §11.5/§12.3). */
|
||||
export const HANDOFF_MILESTONES = ['DEPARTED_FROM_DJIBOUTI', 'DEPARTED_TO_DJIBOUTI'];
|
||||
@@ -0,0 +1,140 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
HANDOFF_MILESTONES,
|
||||
MilestoneDef,
|
||||
milestonesForDirection,
|
||||
splitMilestones,
|
||||
} from './clearance-milestone.catalog';
|
||||
|
||||
/**
|
||||
* Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking
|
||||
* milestones attach to the contract clearance cycle; post-booking milestones attach
|
||||
* to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceMilestoneService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private get repo() {
|
||||
return this.dataSource.getRepository(ClearanceMilestone);
|
||||
}
|
||||
|
||||
/** Seed the pre-booking milestones onto a contract's current clearance cycle. */
|
||||
async seedPreBookingMilestones(
|
||||
contract: Contract,
|
||||
clearanceCycleId: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(contract.tradeDirection);
|
||||
await this.seed(preBooking, {
|
||||
contractId: contract.id,
|
||||
clearanceCycleId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||
async seedPostBookingMilestones(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const { postBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(postBooking, { bookingId });
|
||||
}
|
||||
|
||||
private async seed(
|
||||
defs: MilestoneDef[],
|
||||
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||
): Promise<void> {
|
||||
if (!defs.length) return;
|
||||
const rows = defs.map((def, i) =>
|
||||
this.repo.create({
|
||||
...scope,
|
||||
milestoneCode: def.code,
|
||||
milestoneLabel: def.label,
|
||||
ownerRegion: def.ownerRegion,
|
||||
triggeredByDoc: def.triggeredByDoc,
|
||||
status: 'PENDING',
|
||||
sortOrder: i,
|
||||
}),
|
||||
);
|
||||
await this.repo.save(rows);
|
||||
}
|
||||
|
||||
/** List milestones for a contract cycle or a booking. */
|
||||
async listForContract(contractId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.repo.find({
|
||||
where: { contractId },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.repo.find({
|
||||
where: { bookingId },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a milestone complete (by code) on a booking. */
|
||||
async completeForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
milestone.triggeredByUserId = userId ?? null;
|
||||
if (note) milestone.note = note;
|
||||
const saved = await this.repo.save(milestone);
|
||||
|
||||
if (HANDOFF_MILESTONES.includes(code)) {
|
||||
await this.onHandoff(bookingId, code);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||
async completeByDocTrigger(
|
||||
scope: { bookingId?: string; contractId?: string },
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
const where = scope.bookingId
|
||||
? { bookingId: scope.bookingId, milestoneCode: code }
|
||||
: { contractId: scope.contractId, milestoneCode: code };
|
||||
const milestone = await this.repo.findOne({ where });
|
||||
if (!milestone || milestone.status === 'COMPLETED') return;
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/**
|
||||
* ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the
|
||||
* lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL
|
||||
* Djibouti. Notifications are handled by the notification layer (out of scope);
|
||||
* here we only record the ownership flip on subsequent pending milestones.
|
||||
*/
|
||||
private async onHandoff(bookingId: string, code: string): Promise<void> {
|
||||
void bookingId;
|
||||
void code;
|
||||
// Ownership region is already encoded per-milestone in the catalog; no
|
||||
// mutation is required. This hook exists for the notification dispatch that
|
||||
// the GL US-09 handoff requires once the notification module lands.
|
||||
}
|
||||
|
||||
/** Catalog passthrough for the frontend timeline (labels + owners). */
|
||||
catalogForDirection(tradeDirection: string): MilestoneDef[] {
|
||||
return milestonesForDirection(tradeDirection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
|
||||
export interface CreateBookingUnderContractResult {
|
||||
booking: Booking;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
* - Path A (transport only): the customer creates the booking once the contract
|
||||
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
|
||||
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
|
||||
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
|
||||
* shipment data.
|
||||
*
|
||||
* From booking creation onward the existing batch/payment/allocation pipeline
|
||||
* runs unchanged. See docs/new-doc.md §8, §13.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
// GL Ethiopia is identified by the dedicated contract create-booking permission
|
||||
// (granted to the edr_gl_ethiopia preset).
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
// Validity window must still be open.
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: only one active booking at a time (also enforced by partial unique index).
|
||||
if (contract.contractKind === 'ONE_TIME') {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const warnings: string[] = [];
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
contractKind: contract.contractKind,
|
||||
createdByRole,
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||
} 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) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
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);
|
||||
}
|
||||
|
||||
// Path B side effects: link the clearance cycle, seed post-booking
|
||||
// milestones onto the booking, and advance the contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — GL Ethiopia only.
|
||||
if (!isGlActor) {
|
||||
throw new ForbiddenException(
|
||||
'Only Global Logistics Ethiopia can create bookings for customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
}
|
||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
private async countActiveBookings(contractId: string): Promise<number> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
private async resolveRoute(
|
||||
contract: Contract,
|
||||
contractRouteId?: string,
|
||||
): Promise<ContractRoute | null> {
|
||||
const routes = contract.routes ?? [];
|
||||
if (contractRouteId) {
|
||||
const found = routes.find((r) => r.id === contractRouteId);
|
||||
if (!found) {
|
||||
throw new BadRequestException('Selected route is not part of this contract.');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// ONE_TIME (or single-route GENERAL): auto-select the only route.
|
||||
if (routes.length === 1) return routes[0];
|
||||
if (routes.length === 0) return null;
|
||||
throw new BadRequestException(
|
||||
'contractRouteId is required for multi-route general contracts.',
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCargoTypeId(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): string | null {
|
||||
if (contract.freightType === 'BULK') {
|
||||
const bulk = dto.bulkLines?.[0];
|
||||
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
|
||||
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||
if (!dto.bulkLines?.length) return 0;
|
||||
return dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each contract-scope container size to a concrete container type and
|
||||
* persist the booking_container line + its per-unit container numbers. Weight
|
||||
* rule results are filled in afterward by {@link applyWeightResults} once all
|
||||
* lines exist (a single rule-engine pass over the booking).
|
||||
*/
|
||||
private async persistContainers(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException('At least one container line is required.');
|
||||
}
|
||||
|
||||
const allowedSizes = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
|
||||
: 0;
|
||||
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
|
||||
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
} as Partial<BookingContainer>),
|
||||
);
|
||||
|
||||
let sortOrder = 0;
|
||||
for (const unit of line.units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the rule engine once over the freshly-created booking and persist the
|
||||
* overweight result per container line (same ordering the engine returns).
|
||||
*/
|
||||
private async applyWeightResults(booking: Booking): Promise<void> {
|
||||
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr) continue;
|
||||
await containerRepo.update(containers[i].id, {
|
||||
weightLimitRuleId: wr.weightLimitRuleId,
|
||||
isOverweight: wr.isOverweight,
|
||||
overweightExcessTons: wr.overweightExcessTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||
private async resolveContainerTypeForSize(
|
||||
size: string,
|
||||
preferReefer: boolean,
|
||||
): Promise<ContainerType> {
|
||||
const sizeFt = parseInt(size, 10);
|
||||
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
||||
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||
if (!types.length) {
|
||||
throw new BadRequestException(`No container type configured for size ${size}.`);
|
||||
}
|
||||
if (preferReefer) {
|
||||
const reefer = types.find((t) => t.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return types.find((t) => !t.isReefer) ?? types[0];
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
const seq = String(count + 1).padStart(6, '0');
|
||||
return `BK-${year}-${seq}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
|
||||
export interface ContractClearanceDocument {
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: ContractDocReviewStatus | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface ContractClearanceView {
|
||||
contractId: string;
|
||||
status: string;
|
||||
clearanceStatus: string;
|
||||
cycleNumber: number;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
documents: ContractClearanceDocument[];
|
||||
allApproved: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractClearanceService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
) {}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||
|
||||
const documents: ContractClearanceDocument[] = [];
|
||||
|
||||
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||
} catch {
|
||||
return; // setting not seeded — skip gracefully
|
||||
}
|
||||
for (const field of setting.fields ?? []) {
|
||||
const file = fileByCode.get(field.fileKey) ?? null;
|
||||
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: field.fileKey,
|
||||
label: field.fileLabel,
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
|
||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
|
||||
return {
|
||||
contractId,
|
||||
status: contract.status,
|
||||
clearanceStatus: contract.clearanceStatus,
|
||||
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||
* the current cycle. The 100% gate before clearance can be finalized.
|
||||
*/
|
||||
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
|
||||
const { inputCode } = contractClearanceCodes(contract);
|
||||
if (!inputCode) return true;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contract.id,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents on the contract. When every required
|
||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||
*/
|
||||
async uploadDocuments(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot upload clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
const { inputCode } = contractClearanceCodes(contract);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException('This contract has no document-clearance step');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
// First submission: every required input field must be present.
|
||||
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
|
||||
await this.assertRequiredInputsPresent(contractId, inputCode, files);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
|
||||
await this.contractsRepository.upsertDocumentReviewPending({
|
||||
contractId,
|
||||
clearanceCycleId: cycle?.id ?? null,
|
||||
settingCode,
|
||||
fileKey: file.fieldname,
|
||||
fileRecordId: record.id,
|
||||
uploadedByRole: 'CUSTOMER',
|
||||
});
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
private async assertRequiredInputsPresent(
|
||||
contractId: string,
|
||||
inputCode: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return;
|
||||
|
||||
const existing = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const presentKeys = new Set<string>([
|
||||
...existing.map((f) => f.code),
|
||||
...files.map((f) => f.fieldname),
|
||||
]);
|
||||
|
||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||
throw new BadRequestException(
|
||||
`Please upload all required documents before submitting: ${labels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
|
||||
async reviewDocument(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
|
||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
const match = reviews.find((r) => r.fileKey === fileKey);
|
||||
const settingCode =
|
||||
match?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
|
||||
await this.contractsRepository.setDocumentReviewStatus({
|
||||
contractId,
|
||||
clearanceCycleId: cycle?.id ?? null,
|
||||
settingCode,
|
||||
fileKey,
|
||||
status,
|
||||
staffId,
|
||||
note,
|
||||
});
|
||||
|
||||
if (status === 'QUERIED') {
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
staffId,
|
||||
'GL_ET',
|
||||
);
|
||||
// Return the contract to the customer to re-upload the queried document.
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
}
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
|
||||
async uploadOutputDocuments(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This contract has no customs output documents');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET finalizes pre-booking clearance: requires every customer document
|
||||
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
throw new BadRequestException(
|
||||
'All required documents must be approved before clearance can be finalized',
|
||||
);
|
||||
}
|
||||
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const uploaded = new Set(files.map((f) => f.code));
|
||||
const missing = (setting.fields ?? []).filter(
|
||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Upload all required customs output documents first: ${missing
|
||||
.map((m) => m.fileLabel)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(
|
||||
cycle.id,
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
{ clearanceReadyAt: new Date() },
|
||||
);
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
|
||||
* CLEARANCE_UNDER_REVIEW (customs contracts only).
|
||||
*/
|
||||
async queue(
|
||||
filter: FilterContractDto,
|
||||
region?: string,
|
||||
): Promise<PaginatedContracts> {
|
||||
void region; // single ET pre-booking queue today; region reserved for split
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||
* the CONTRACT pre-booking phase (Path B). Mirrors clearance.util.ts but emits
|
||||
* `contract_clearance_*` codes keyed on (tradeDirection, freightType, customs).
|
||||
*/
|
||||
|
||||
type Op = 'import' | 'export';
|
||||
type Freight = 'container' | 'bulk';
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
function freightFor(freightType: string): Freight {
|
||||
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||
}
|
||||
|
||||
/** The customer-input clearance setting code, or null when no gate applies. */
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
return `contract_clearance_${op}_${freight}`;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code; only container customs sets exist. */
|
||||
export function contractClearanceOutputSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
if (freightFor(freightType) !== 'container') return null;
|
||||
return `contract_clearance_output_${op}_container`;
|
||||
}
|
||||
|
||||
/** Convenience: resolve both codes for a loaded contract. */
|
||||
export function contractClearanceCodes(contract: Contract): {
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
const includesCustoms = contract.customsClearingEnabled ?? false;
|
||||
return {
|
||||
inputCode: contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
outputCode: contractClearanceOutputSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
|
||||
export interface ContractUnitRateLineItem {
|
||||
code: string;
|
||||
label: string;
|
||||
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||
unitPrice: number;
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
}
|
||||
|
||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||
export interface ContractPricingBreakdown {
|
||||
displayMode: 'UNIT_RATES';
|
||||
currency: string;
|
||||
lineItems: ContractUnitRateLineItem[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Map a rate's storage unit to the contract-display unit. */
|
||||
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
switch (rateUnit) {
|
||||
case 'PER_TON':
|
||||
return 'per_ton';
|
||||
case 'PER_KM':
|
||||
return 'per_km';
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_WAGON':
|
||||
return 'per_container';
|
||||
default:
|
||||
return 'flat';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractPricingService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
|
||||
/** Base rail rate type for the contract's direction + freight. */
|
||||
private baseRateType(contract: Contract): string {
|
||||
const isBulk = contract.freightType === 'BULK';
|
||||
if (contract.tradeDirection === 'IMPORT') {
|
||||
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
|
||||
}
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
|
||||
}
|
||||
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
|
||||
* (one per container size, conditional hazard/reefer surcharges, and bulk
|
||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||
*/
|
||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 500,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||||
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||||
const rate =
|
||||
liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === baseType &&
|
||||
r.currency === 'USD' &&
|
||||
r.containerTypeId &&
|
||||
matchedIds.has(r.containerTypeId),
|
||||
) ??
|
||||
liveRates.find(
|
||||
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
|
||||
);
|
||||
if (!rate) continue;
|
||||
lineItems.push({
|
||||
code: `CONTAINER_${size.toUpperCase()}`,
|
||||
label: `${size} container`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const bulkRate =
|
||||
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
if (bulkRate) {
|
||||
lineItems.push({
|
||||
code: 'BULK_FREIGHT',
|
||||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||
unit: toContractUnit(bulkRate.rateUnit),
|
||||
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional surcharges — shown only when the contract toggles them on.
|
||||
if (contract.isHazardous) {
|
||||
const hazard = liveRates.find(
|
||||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (hazard) {
|
||||
lineItems.push({
|
||||
code: 'HAZARD_SURCHARGE',
|
||||
label: 'Hazardous surcharge',
|
||||
unit: toContractUnit(hazard.rateUnit),
|
||||
unitPrice: convert(Number(hazard.rateValue)),
|
||||
conditionalOn: 'is_hazardous',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (contract.isReefer) {
|
||||
const reefer = liveRates.find(
|
||||
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (reefer) {
|
||||
lineItems.push({
|
||||
code: 'REEFER_SURCHARGE',
|
||||
label: 'Reefer surcharge',
|
||||
unit: toContractUnit(reefer.rateUnit),
|
||||
unitPrice: convert(Number(reefer.rateValue)),
|
||||
conditionalOn: 'is_reefer',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
currency,
|
||||
lineItems,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate (and persist) the unit-rate breakdown for a contract. */
|
||||
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) {
|
||||
throw new Error(`Contract ${contractId} not found`);
|
||||
}
|
||||
const breakdown = await this.buildBreakdown(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
pricingBreakdown: breakdown as never,
|
||||
pricingDisplayMode: 'UNIT_RATES',
|
||||
} as never);
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
|
||||
* rate line) at submit time. The booking later computes totals from these.
|
||||
*/
|
||||
async freezeRateSnapshots(contractId: string): Promise<void> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) return;
|
||||
const breakdown =
|
||||
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
|
||||
(await this.buildBreakdown(contract));
|
||||
|
||||
await this.contractsRepository.clearRateSnapshots(contractId);
|
||||
for (const line of breakdown.lineItems) {
|
||||
await this.contractsRepository.createRateSnapshot({
|
||||
contractId,
|
||||
rateCode: line.code,
|
||||
description: line.label,
|
||||
unitPrice: line.unitPrice,
|
||||
unitOfMeasure: line.unit,
|
||||
currency: breakdown.currency,
|
||||
containerSize: line.containerSize ?? null,
|
||||
isSurcharge: !!line.conditionalOn,
|
||||
conditionalOn: line.conditionalOn ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractTransitionService {
|
||||
private readonly logger = new Logger(ContractTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly approvalRulesService: ApprovalRulesService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
async submit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
|
||||
await this.pricingService.generatePrice(contractId);
|
||||
await this.pricingService.freezeRateSnapshots(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
||||
async confirmSubmit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||
|
||||
await this.pricingService.generatePrice(contractId);
|
||||
await this.pricingService.freezeRateSnapshots(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line staff accepts intake: set the validity window from validityDays and
|
||||
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
|
||||
*/
|
||||
async staffAccept(
|
||||
contractId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
throw new BadRequestException(
|
||||
'A contract validity (in days) is required to accept this contract.',
|
||||
);
|
||||
}
|
||||
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||
* booking transition's instantiateApprovalSteps but writes contract steps.
|
||||
*/
|
||||
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
|
||||
if ((contract.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
const cargoTypeId =
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||
|
||||
// US-06 routing: bulk always needs director approval; container needs it only
|
||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||
// source of truth the booking flow uses (no booking row is created here).
|
||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||
if (cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||
if (chain.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of chain) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async requestChanges(
|
||||
contractId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
note,
|
||||
'CHANGES_REQUESTED',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CHANGES_REQUESTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||
async approveStep(
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Contract> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
}
|
||||
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException('Approval steps must be completed in order');
|
||||
}
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
if (step.blocksRole && step.blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
}
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
|
||||
if (allDone) {
|
||||
updates.status = 'APPROVED';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.contractsRepository.update(contractId, updates as never);
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
|
||||
try {
|
||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
contractTemplateKey: view.templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the contract PDF view-model and rendered HTML for portal/backoffice
|
||||
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
|
||||
* totals). Returns the view-model, the rendered HTML and the signature rows.
|
||||
*/
|
||||
async getContractDocumentView(contractId: string): Promise<{
|
||||
view: ContractViewModel;
|
||||
html: string;
|
||||
signatures: ContractViewModel['signatures'];
|
||||
}> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
return { view, html, signatures: view.signatures };
|
||||
}
|
||||
|
||||
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
||||
private async upsertContractPdf(
|
||||
contractId: string,
|
||||
reference: string,
|
||||
view: ContractViewModel,
|
||||
): Promise<FileRecord> {
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'contract',
|
||||
originalname: `contract-${reference}.pdf`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/pdf',
|
||||
size: pdfBuffer.length,
|
||||
buffer: pdfBuffer,
|
||||
stream: Readable.from(pdfBuffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
return this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'contract',
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
||||
private async inlineSignatureImages(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
try {
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a digital signature row (mirrors booking-contract.service). */
|
||||
private async applySignature(
|
||||
contract: Contract,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<void> {
|
||||
const role = dto.role as ContractSignerRole;
|
||||
const raw = dto.signatureImageBase64.includes(',')
|
||||
? dto.signatureImageBase64.split(',')[1]!
|
||||
: dto.signatureImageBase64;
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const sigFile: Express.Multer.File = {
|
||||
fieldname: `signature_${role.toLowerCase()}`,
|
||||
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: contract.id,
|
||||
resource: 'contracts',
|
||||
code: `signature_${role.toLowerCase()}`,
|
||||
file: sigFile,
|
||||
});
|
||||
|
||||
await this.contractsRepository.saveSignature({
|
||||
contractId: contract.id,
|
||||
role,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signedAt: new Date(),
|
||||
signatureFileId: fileRecord.id,
|
||||
consentText: dto.consentText ?? null,
|
||||
});
|
||||
|
||||
if (options.signerUserId) {
|
||||
try {
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId: options.signerUserId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
async sign(
|
||||
contractId: string,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (dto.role === 'CUSTOMER') {
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
return this.counterSign(contractId, dto, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff/Director/CEO counter-sign → branch on customs:
|
||||
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
|
||||
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
|
||||
*/
|
||||
async counterSign(
|
||||
contractId: string,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
||||
|
||||
await this.applySignature(contract, dto, options);
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: now,
|
||||
};
|
||||
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — open a clearance cycle, seed the pre-booking milestones, and
|
||||
// route the customer to the document upload.
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// Path A — transport only; ready for the customer to book.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, updates as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
const reference = await this.generateRenewalReference();
|
||||
const renewal = await this.contractsRepository.create({
|
||||
reference,
|
||||
companyId: source.companyId,
|
||||
companyProfileId: source.companyProfileId,
|
||||
isGovernment: source.isGovernment,
|
||||
governmentInstitution: source.governmentInstitution,
|
||||
contractKind: source.contractKind,
|
||||
renewalOfId: source.id,
|
||||
tradeDirection: source.tradeDirection,
|
||||
freightType: source.freightType,
|
||||
serviceTypeId: source.serviceTypeId,
|
||||
paymentCurrency: source.paymentCurrency,
|
||||
customsClearingEnabled: source.customsClearingEnabled,
|
||||
customsClearingAgent: source.customsClearingAgent,
|
||||
equipmentReturn: source.equipmentReturn,
|
||||
firstMilePickupAddress: source.firstMilePickupAddress,
|
||||
firstMilePickupLat: source.firstMilePickupLat,
|
||||
firstMilePickupLng: source.firstMilePickupLng,
|
||||
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: source.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: source.lastMileDeliveryLng,
|
||||
isHazardous: source.isHazardous,
|
||||
isReefer: source.isReefer,
|
||||
contractType: source.contractType,
|
||||
versionNumber: (source.versionNumber ?? 1) + 1,
|
||||
status: 'RENEWAL_DRAFT',
|
||||
clearanceStatus: 'NOT_APPLICABLE',
|
||||
clearanceCycleNumber: 0,
|
||||
} as never);
|
||||
|
||||
void userId;
|
||||
return this.contractsService.findById(renewal.id);
|
||||
}
|
||||
|
||||
private async generateRenewalReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.contractsRepository.countByYear(year);
|
||||
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
RequestChangesDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
@ApiTags('contracts')
|
||||
@Controller('contracts')
|
||||
@ApiBearerAuth()
|
||||
export class ContractsController {
|
||||
constructor(
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly transitionService: ContractTransitionService,
|
||||
private readonly clearanceService: ContractClearanceService,
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
|
||||
@ApiBody({ type: CreateContractDto })
|
||||
async create(
|
||||
@Body() dto: CreateContractDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||
}
|
||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List contracts (paginated)' })
|
||||
async findAll(
|
||||
@Query() filter: FilterContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff see every contract; customers are force-scoped to their own company.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.contractsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||
if (!companyId) {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.contractsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: "List the current customer's contracts" })
|
||||
async findMy(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() filter: FilterContractDto,
|
||||
) {
|
||||
const userId = resolveAuthUserId(user);
|
||||
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||
if (!companyId) {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.contractsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterContractDto) {
|
||||
return this.contractsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
|
||||
clearanceQueue(
|
||||
@Query() filter: FilterContractDto,
|
||||
@Query('region') region?: string,
|
||||
) {
|
||||
return this.clearanceService.queue(filter, region);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Update contract',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
})
|
||||
@ApiBody({ type: UpdateContractDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.contractsService.update(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
|
||||
uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.contractsService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@ApiOperation({ summary: 'Confirm submit after a price change' })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||
staffAccept(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.staffAccept(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||
@ApiOperation({ summary: 'Staff reject contract' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
||||
generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.generateContract(id);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
|
||||
async getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { view, html, signatures } =
|
||||
await this.transitionService.getContractDocumentView(id);
|
||||
return {
|
||||
contractId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
view,
|
||||
canSignCustomer: view.canSignCustomer,
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.sign(id, dto, {
|
||||
signerUserId: user?.id ?? user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||
reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewClearanceDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.reviewDocument(
|
||||
id,
|
||||
dto.fileKey,
|
||||
dto.status,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' })
|
||||
uploadOutputDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.clearanceService.uploadOutputDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
||||
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalize(id);
|
||||
}
|
||||
|
||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||
|
||||
@Post(':id/bookings')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||
})
|
||||
createBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
// The service decides the execution path from the contract:
|
||||
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||
return this.contractBookingService.createUnderContract(
|
||||
id,
|
||||
dto,
|
||||
{ id: user?.id ?? user?.sub },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@Get(':id/milestones')
|
||||
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
|
||||
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.milestoneService.listForContract(id);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/milestones')
|
||||
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
|
||||
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.milestoneService.listForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/milestones/:code/complete')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' })
|
||||
completeBookingMilestone(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Param('code') code: string,
|
||||
@Body() body: { note?: string },
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.milestoneService.completeForBooking(
|
||||
bookingId,
|
||||
code,
|
||||
user?.id ?? user?.sub,
|
||||
body?.note,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './entities/contract-signature.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Contract,
|
||||
ContractRoute,
|
||||
ContractCargoScope,
|
||||
ContractRateSnapshot,
|
||||
ContractSignature,
|
||||
ContractApprovalStep,
|
||||
ContractReviewNote,
|
||||
ContractClearanceCycle,
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
BookingContainerUnit,
|
||||
]),
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
SignaturesModule,
|
||||
CompaniesModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
BookingsModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [ContractsController],
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
// helpers reused from src/contracts/.
|
||||
ContractTemplateResolver,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
ContractDocumentViewModelBuilder,
|
||||
],
|
||||
exports: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import {
|
||||
ContractDocReviewStatus,
|
||||
ContractDocumentReview,
|
||||
} from './entities/contract-document-review.entity';
|
||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
||||
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||
|
||||
export interface ContractListFilterOptions {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
companyId?: string;
|
||||
companyProfileId?: string;
|
||||
contractKind?: string;
|
||||
serviceTypeId?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractsRepository extends BaseRepository<Contract> {
|
||||
constructor(
|
||||
@InjectRepository(Contract)
|
||||
repository: Repository<Contract>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Find a contract by its human-readable reference number. */
|
||||
findByReference(reference: string): Promise<Contract | null> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/** Count contracts created in a specific year. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const startDate = new Date(year, 0, 1);
|
||||
const endDate = new Date(year + 1, 0, 1);
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.where('contract.created_at >= :startDate', { startDate })
|
||||
.andWhere('contract.created_at < :endDate', { endDate })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||
if (!id) return null;
|
||||
|
||||
const contract = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.leftJoinAndSelect('contract.routes', 'routes')
|
||||
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
|
||||
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
||||
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
||||
.leftJoinAndSelect('contract.signatures', 'signatures')
|
||||
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
||||
.leftJoinAndSelect('contract.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('contract.company', 'company')
|
||||
.where('contract.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'contract.files',
|
||||
FileRecord,
|
||||
'file',
|
||||
"file.resource_id = contract.id AND file.resource = 'contracts'",
|
||||
)
|
||||
.getOne();
|
||||
|
||||
return contract ?? null;
|
||||
}
|
||||
|
||||
/** Paginated list with optional multi-status filter (API tab queues). */
|
||||
async findAllPaginated(
|
||||
options: ContractListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
},
|
||||
): Promise<{
|
||||
items: Contract[];
|
||||
total: number;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}> {
|
||||
const page = options.page;
|
||||
const pageSize = options.pageSize;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.leftJoinAndSelect('contract.company', 'company')
|
||||
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('contract.routes', 'routes')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.where('contract.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
const sortField =
|
||||
options.sortBy === 'contractValidUntil'
|
||||
? 'contract.contractValidUntil'
|
||||
: 'contract.createdAt';
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
|
||||
const [items, total] = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.select('contract.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('contract.deleted_at IS NULL')
|
||||
.groupBy('contract.status')
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
|
||||
async getListSummaryMetrics(
|
||||
options: ContractListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
needsActionStatuses: readonly string[];
|
||||
},
|
||||
): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> {
|
||||
const baseQb = () => {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.where('contract.deleted_at IS NULL');
|
||||
this.applyListFilters(qb, options);
|
||||
return qb;
|
||||
};
|
||||
|
||||
const inQueue = await baseQb().getCount();
|
||||
|
||||
const needsAction = await baseQb()
|
||||
.andWhere('contract.status IN (:...needsActionStatuses)', {
|
||||
needsActionStatuses: [...options.needsActionStatuses],
|
||||
})
|
||||
.getCount();
|
||||
|
||||
const offset = (options.page - 1) * options.pageSize;
|
||||
const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset));
|
||||
|
||||
return { inQueue, onThisPage, needsAction };
|
||||
}
|
||||
|
||||
private applyListFilters(
|
||||
qb: SelectQueryBuilder<Contract>,
|
||||
options: ContractListFilterOptions,
|
||||
): void {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||
} else if (options.status) {
|
||||
qb.andWhere('contract.status = :status', { status: options.status });
|
||||
}
|
||||
if (options.companyId) {
|
||||
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||
}
|
||||
if (options.companyProfileId) {
|
||||
qb.andWhere('contract.company_profile_id = :companyProfileId', {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractKind) {
|
||||
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||
contractKind: options.contractKind,
|
||||
});
|
||||
}
|
||||
if (options.serviceTypeId) {
|
||||
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
if (options.freightType) {
|
||||
qb.andWhere('contract.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('contract.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
});
|
||||
}
|
||||
if (options.createdTo) {
|
||||
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
async findNextPendingApprovalStep(
|
||||
contractId: string,
|
||||
): Promise<ContractApprovalStep | null> {
|
||||
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
||||
where: { contractId, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findApprovalStepById(
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
): Promise<ContractApprovalStep | null> {
|
||||
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
||||
where: { contractId, id: stepId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
async completeApprovalStep(
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
status: 'APPROVED' | 'REJECTED',
|
||||
note?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(ContractApprovalStep).update(stepId, {
|
||||
status,
|
||||
actedByStaffId: actorId,
|
||||
actedAt: new Date(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if all approval steps are approved. */
|
||||
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
|
||||
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
|
||||
where: { contractId, status: 'PENDING' },
|
||||
});
|
||||
return pending === 0;
|
||||
}
|
||||
|
||||
/** Persist a contract approval step (instantiated at staff accept). */
|
||||
async createApprovalStep(
|
||||
data: Partial<ContractApprovalStep>,
|
||||
): Promise<ContractApprovalStep> {
|
||||
const repo = this.dataSource.getRepository(ContractApprovalStep);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
// ── Signatures ──────────────────────────────────────────────────────────────
|
||||
|
||||
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
||||
return this.dataSource.getRepository(ContractSignature).find({
|
||||
where: { contractId },
|
||||
relations: ['signatureFile'],
|
||||
order: { signedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findSignature(
|
||||
contractId: string,
|
||||
role: ContractSignerRole,
|
||||
): Promise<ContractSignature | null> {
|
||||
return this.dataSource.getRepository(ContractSignature).findOne({
|
||||
where: { contractId, role },
|
||||
relations: ['signatureFile'],
|
||||
});
|
||||
}
|
||||
|
||||
async saveSignature(data: Partial<ContractSignature>): Promise<ContractSignature> {
|
||||
const repo = this.dataSource.getRepository(ContractSignature);
|
||||
const existing = await repo.findOne({
|
||||
where: { contractId: data.contractId!, role: data.role! },
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, data);
|
||||
return repo.save(existing);
|
||||
}
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
// ── Review notes ──────────────────────────────────────────────────────────────
|
||||
|
||||
async createReviewNote(
|
||||
contractId: string,
|
||||
body: string,
|
||||
noteType: ContractReviewNoteType,
|
||||
authorUserId?: string,
|
||||
authorRole?: string,
|
||||
): Promise<ContractReviewNote> {
|
||||
const repo = this.dataSource.getRepository(ContractReviewNote);
|
||||
return repo.save(
|
||||
repo.create({
|
||||
contractId,
|
||||
body,
|
||||
noteType,
|
||||
authorUserId: authorUserId ?? null,
|
||||
authorRole: authorRole ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findLatestReviewNote(
|
||||
contractId: string,
|
||||
noteType?: ContractReviewNoteType,
|
||||
): Promise<ContractReviewNote | null> {
|
||||
const repo = this.dataSource.getRepository(ContractReviewNote);
|
||||
return repo.findOne({
|
||||
where: noteType ? { contractId, noteType } : { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance document reviews ────────────────────────────────────
|
||||
|
||||
findDocumentReviews(
|
||||
contractId: string,
|
||||
cycleId?: string | null,
|
||||
): Promise<ContractDocumentReview[]> {
|
||||
return this.dataSource.getRepository(ContractDocumentReview).find({
|
||||
where:
|
||||
cycleId !== undefined
|
||||
? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId }
|
||||
: { contractId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
|
||||
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed
|
||||
* on (contractId, clearanceCycleId, settingCode, fileKey).
|
||||
*/
|
||||
async upsertDocumentReviewPending(input: {
|
||||
contractId: string;
|
||||
clearanceCycleId?: string | null;
|
||||
settingCode: string;
|
||||
fileKey: string;
|
||||
fileRecordId: string;
|
||||
uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ';
|
||||
}): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
||||
const cycleId = input.clearanceCycleId ?? null;
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
contractId: input.contractId,
|
||||
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
||||
settingCode: input.settingCode,
|
||||
fileKey: input.fileKey,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await repo.update(existing.id, {
|
||||
fileRecordId: input.fileRecordId,
|
||||
status: 'PENDING',
|
||||
note: null,
|
||||
reviewedByStaffId: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await repo.save(
|
||||
repo.create({
|
||||
contractId: input.contractId,
|
||||
clearanceCycleId: cycleId,
|
||||
settingCode: input.settingCode,
|
||||
fileKey: input.fileKey,
|
||||
fileRecordId: input.fileRecordId,
|
||||
status: 'PENDING',
|
||||
uploadedByRole: input.uploadedByRole ?? 'CUSTOMER',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||
async setDocumentReviewStatus(input: {
|
||||
contractId: string;
|
||||
clearanceCycleId?: string | null;
|
||||
settingCode: string;
|
||||
fileKey: string;
|
||||
status: ContractDocReviewStatus;
|
||||
staffId: string;
|
||||
note?: string;
|
||||
}): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
||||
const cycleId = input.clearanceCycleId ?? null;
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
contractId: input.contractId,
|
||||
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
||||
settingCode: input.settingCode,
|
||||
fileKey: input.fileKey,
|
||||
},
|
||||
});
|
||||
const patch = {
|
||||
status: input.status,
|
||||
note: input.note ?? null,
|
||||
reviewedByStaffId: input.staffId,
|
||||
reviewedAt: new Date(),
|
||||
};
|
||||
if (existing) {
|
||||
await repo.update(existing.id, patch);
|
||||
return;
|
||||
}
|
||||
await repo.save(
|
||||
repo.create({
|
||||
contractId: input.contractId,
|
||||
clearanceCycleId: cycleId,
|
||||
settingCode: input.settingCode,
|
||||
fileKey: input.fileKey,
|
||||
...patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Clearance cycles ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The current (latest, non-completed) clearance cycle for a contract. */
|
||||
async currentCycle(contractId: string): Promise<ContractClearanceCycle | null> {
|
||||
return this.dataSource.getRepository(ContractClearanceCycle).findOne({
|
||||
where: { contractId },
|
||||
order: { cycleNumber: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a new clearance cycle (incrementing cycle_number). */
|
||||
async openCycle(
|
||||
contractId: string,
|
||||
cycleNumber: number,
|
||||
): Promise<ContractClearanceCycle> {
|
||||
const repo = this.dataSource.getRepository(ContractClearanceCycle);
|
||||
return repo.save(
|
||||
repo.create({
|
||||
contractId,
|
||||
cycleNumber,
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async setCycleStatus(
|
||||
cycleId: string,
|
||||
status: string,
|
||||
fields: Partial<
|
||||
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
||||
> = {},
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(ContractClearanceCycle)
|
||||
.update(cycleId, { status, ...fields } as never);
|
||||
}
|
||||
|
||||
/** Link the GL-created booking to a clearance cycle. */
|
||||
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(ContractClearanceCycle)
|
||||
.update(cycleId, { bookingId });
|
||||
}
|
||||
|
||||
// ── Rate snapshots ──────────────────────────────────────────────────────────
|
||||
|
||||
async createRateSnapshot(
|
||||
data: Partial<ContractRateSnapshot>,
|
||||
): Promise<ContractRateSnapshot> {
|
||||
const repo = this.dataSource.getRepository(ContractRateSnapshot);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
async clearRateSnapshots(contractId: string): Promise<void> {
|
||||
await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId });
|
||||
}
|
||||
|
||||
findRateSnapshots(contractId: string): Promise<ContractRateSnapshot[]> {
|
||||
return this.dataSource.getRepository(ContractRateSnapshot).find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
481
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
481
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedContracts {
|
||||
items: Contract[];
|
||||
total: number;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'SIGNED_CUSTOMER',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ContractsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.contractsRepository.countByYear(year);
|
||||
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
/** Whether a service type bundles customs clearance. */
|
||||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||
const serviceType = await this.dataSource
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: serviceTypeId } });
|
||||
return serviceType?.includesCustoms ?? false;
|
||||
}
|
||||
|
||||
/** Validate cargo-scope rows against freight type (doc §5.4). */
|
||||
private assertCargoScopeShape(
|
||||
freightType: string,
|
||||
cargoScope: CreateContractDto['cargoScope'],
|
||||
): void {
|
||||
if (freightType === 'CONTAINER') {
|
||||
const sizes = cargoScope.filter((c) =>
|
||||
['20ft', '40ft'].includes(c.containerSize ?? ''),
|
||||
);
|
||||
if (sizes.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'CONTAINER contracts require at least one container size (20ft/40ft) in scope',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const bulk = cargoScope.filter((c) => c.cargoTypeId);
|
||||
if (bulk.length !== 1) {
|
||||
throw new BadRequestException(
|
||||
'BULK contracts require exactly one cargo-type scope row',
|
||||
);
|
||||
}
|
||||
if (cargoScope.some((c) => c.containerSize)) {
|
||||
throw new BadRequestException('BULK contracts must not set a container size');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate route count against contract kind (doc §5.3). */
|
||||
private assertRouteShape(
|
||||
contractKind: string,
|
||||
routes: CreateContractDto['routes'],
|
||||
): void {
|
||||
if (contractKind === 'ONE_TIME' && routes.length !== 1) {
|
||||
throw new BadRequestException('ONE_TIME contracts require exactly one route');
|
||||
}
|
||||
if (routes.length < 1) {
|
||||
throw new BadRequestException('A contract requires at least one route');
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||
async create(
|
||||
dto: CreateContractDto,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'governmentInstitution is required for government contracts',
|
||||
);
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
'companyId is required or must be resolvable from auth token',
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create contracts yet.",
|
||||
);
|
||||
}
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
|
||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearing is owned by the service type, not the customer.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
|
||||
const contract = await this.contractsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
contractKind: dto.contractKind,
|
||||
renewalOfId: dto.renewalOfId ?? null,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
customsClearingEnabled: includesCustoms,
|
||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: dto.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
: null,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
clearanceStatus: 'NOT_APPLICABLE',
|
||||
clearanceCycleNumber: 0,
|
||||
} as never);
|
||||
|
||||
await this.persistRoutes(contract.id, dto.routes);
|
||||
await this.persistCargoScope(contract.id, dto.cargoScope);
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
await this.filesService.uploadMany(contract.id, 'contracts', files);
|
||||
} catch {
|
||||
warnings.push('File upload failed — contract was created without attached files.');
|
||||
}
|
||||
}
|
||||
|
||||
return { contract: await this.findById(contract.id), warnings };
|
||||
}
|
||||
|
||||
private async persistRoutes(
|
||||
contractId: string,
|
||||
routes: CreateContractDto['routes'],
|
||||
): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ContractRoute);
|
||||
await repo.save(
|
||||
routes.map((r, i) =>
|
||||
repo.create({
|
||||
contractId,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
km: r.km ?? null,
|
||||
sortOrder: r.sortOrder ?? i,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async persistCargoScope(
|
||||
contractId: string,
|
||||
cargoScope: CreateContractDto['cargoScope'],
|
||||
): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ContractCargoScope);
|
||||
await repo.save(
|
||||
cargoScope.map((c) =>
|
||||
repo.create({
|
||||
contractId,
|
||||
containerSize: c.containerSize ?? null,
|
||||
cargoTypeId: c.cargoTypeId ?? null,
|
||||
cargoFreeText: c.cargoFreeText ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Update a DRAFT / CHANGES_REQUESTED contract. */
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateContractDto,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||
throw new BadRequestException(
|
||||
'Only DRAFT or CHANGES_REQUESTED contracts can be updated',
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const freightType = dto.freightType ?? existing.freightType;
|
||||
const contractKind = dto.contractKind ?? existing.contractKind;
|
||||
|
||||
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||||
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
contractKind,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
freightType,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||
firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng,
|
||||
lastMileDeliveryAddress:
|
||||
dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
|
||||
contractType: dto.contractType ?? existing.contractType,
|
||||
};
|
||||
if (dto.estimatedShipmentDate) {
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
}
|
||||
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
|
||||
|
||||
// Customs clearing always mirrors the (possibly changed) service type.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(
|
||||
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
);
|
||||
updates.customsClearingEnabled = includesCustoms;
|
||||
updates.customsClearingAgent = includesCustoms
|
||||
? null
|
||||
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
|
||||
|
||||
await this.contractsRepository.update(id, updates);
|
||||
|
||||
if (dto.routes) {
|
||||
await this.dataSource.getRepository(ContractRoute).delete({ contractId: id });
|
||||
await this.persistRoutes(id, dto.routes);
|
||||
}
|
||||
if (dto.cargoScope) {
|
||||
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
|
||||
await this.persistCargoScope(id, dto.cargoScope);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await this.filesService.uploadMany(id, 'contracts', files);
|
||||
}
|
||||
|
||||
return { contract: await this.findById(id), warnings };
|
||||
}
|
||||
|
||||
/** Parse comma-separated or repeated status query values. */
|
||||
private parseStatusFilter(filter: FilterContractDto): {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
} {
|
||||
const allowed = new Set<string>(CONTRACT_STATUSES);
|
||||
const raw = filter.statuses;
|
||||
const statusList = raw
|
||||
? raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => allowed.has(s))
|
||||
: [];
|
||||
|
||||
if (statusList.length > 0) return { statuses: statusList };
|
||||
if (filter.status && allowed.has(filter.status)) return { status: filter.status };
|
||||
return {};
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: FilterContractDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
): Promise<PaginatedContracts> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
...statusFilter,
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||
contractKind: filter.contractKind,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate metrics and status counts for the backoffice contract list. */
|
||||
async getListSummary(filter: FilterContractDto): Promise<ContractListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
const listFilter = {
|
||||
...statusFilter,
|
||||
companyId: filter.companyId,
|
||||
contractKind: filter.contractKind,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
};
|
||||
|
||||
const [statusCounts, metrics] = await Promise.all([
|
||||
this.contractsRepository.getStatusCounts(),
|
||||
this.contractsRepository.getListSummaryMetrics({
|
||||
...listFilter,
|
||||
page,
|
||||
pageSize,
|
||||
needsActionStatuses: NEEDS_ACTION_STATUSES,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { metrics, statusCounts };
|
||||
}
|
||||
|
||||
/** Get a single contract by ID with relations and signed file URLs. */
|
||||
async findById(id: string): Promise<Contract> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(id);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${id} not found`);
|
||||
}
|
||||
|
||||
if (contract.files && contract.files.length > 0) {
|
||||
contract.files = await Promise.all(
|
||||
contract.files.map(async (file: FileRecord) => {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||
return { ...file, signedUrl } as FileRecord;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
async findByReference(reference: string): Promise<Contract> {
|
||||
const found = await this.contractsRepository.findByReference(reference);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Contract with reference "${reference}" not found`);
|
||||
}
|
||||
return this.findById(found.id);
|
||||
}
|
||||
|
||||
/** Upload intake documents for a DRAFT contract. */
|
||||
async uploadDocuments(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.findById(id);
|
||||
if (contract.status !== 'DRAFT') {
|
||||
throw new BadRequestException(
|
||||
'Documents can only be uploaded for DRAFT contracts',
|
||||
);
|
||||
}
|
||||
await this.filesService.uploadMany(id, 'contracts', files);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const contract = await this.findById(id);
|
||||
if (contract.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT contracts can be deleted');
|
||||
}
|
||||
await this.contractsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Resolve the company a customer user belongs to, for scoping their contracts. */
|
||||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Authorize a customer's access to a single contract (hides as NotFound otherwise). */
|
||||
async assertCustomerCanAccessContract(
|
||||
userId: string | undefined,
|
||||
contract: Contract,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||
if (!companyId || contract.companyId !== companyId) {
|
||||
throw new NotFoundException(`Contract ${contract.id} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
|
||||
export class AcceptContractDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'How many days the contract stays valid, counted from the accept date. ' +
|
||||
'The contract is valid from now through now + validityDays.',
|
||||
minimum: 1,
|
||||
maximum: 3650,
|
||||
example: 365,
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ApproveStepDto {
|
||||
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||
@IsString()
|
||||
requiredRole!: string;
|
||||
}
|
||||
|
||||
export class RequestChangesDto {
|
||||
@ApiProperty({ description: 'Note explaining what the customer must fix' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
export class RejectContractDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class RejectStepDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class CancelContractDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ContractListSummaryMetricsDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
inQueue!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
onThisPage!: number;
|
||||
|
||||
@ApiProperty({ example: 8 })
|
||||
needsAction!: number;
|
||||
}
|
||||
|
||||
export class ContractListSummaryDto {
|
||||
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||
metrics!: ContractListSummaryMetricsDto;
|
||||
|
||||
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||
statusCounts!: Record<string, number>;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmTons!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
export class CreateBookingContainerLineDto {
|
||||
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContainerUnitDto)
|
||||
units!: CreateContainerUnitDto[];
|
||||
}
|
||||
|
||||
export class CreateBulkLineDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
itemCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
}
|
||||
|
||||
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
|
||||
export class CreateBookingUnderContractDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingContainerLineDto)
|
||||
containers?: CreateBookingContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBulkLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
TRADE_DIRECTIONS,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
EQUIPMENT_RETURNS,
|
||||
};
|
||||
|
||||
/** One cargo-scope row — a container size OR a bulk commodity. NO quantities. */
|
||||
export class CreateContractCargoScopeDto {
|
||||
@ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
containerSize?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
cargoFreeText?: string | null;
|
||||
}
|
||||
|
||||
/** A contracted lane (origin → destination). Routes carry NO quantity. */
|
||||
export class CreateContractRouteInputDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road billing distance (km); null for rail-only.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
km?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class CreateContractDto {
|
||||
@ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Staff only: government contract flag' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
|
||||
renewalOfId?: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
@ApiProperty({ enum: FREIGHT_TYPES })
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
customsClearingEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstMilePickupAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLng?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_hazardous' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
|
||||
example: '2026-07-15T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
contractType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [CreateContractCargoScopeDto],
|
||||
description:
|
||||
'Cargo scope rows. CONTAINER: ≥1 size row. BULK: exactly one cargo-type row.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContractCargoScopeDto)
|
||||
cargoScope!: CreateContractCargoScopeDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [CreateContractRouteInputDto],
|
||||
description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContractRouteInputDto)
|
||||
routes!: CreateContractRouteInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export class FilterContractDto {
|
||||
@ApiPropertyOptional({ enum: CONTRACT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CONTRACT_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by statuses: comma-separated or repeated query params. Overrides status when set.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.map(String).join(',');
|
||||
return String(value);
|
||||
})
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CONTRACT_KINDS })
|
||||
@IsOptional()
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/before this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'contractValidUntil'])
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class RenewContractDto {
|
||||
@ApiPropertyOptional({ description: 'Reference of the prior contract being renewed.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
previousContractReference?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ReviewClearanceDocumentDto {
|
||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fileKey!: string;
|
||||
|
||||
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
|
||||
@IsIn(['APPROVED', 'QUERIED'])
|
||||
status!: 'APPROVED' | 'QUERIED';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when querying a document' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SignContractDto {
|
||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
|
||||
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
|
||||
|
||||
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
signatureImageBase64!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
consentText?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateContractDto } from './create-contract.dto';
|
||||
|
||||
/** Partial contract update — allowed only in DRAFT / CHANGES_REQUESTED. */
|
||||
export class UpdateContractDto extends PartialType(CreateContractDto) {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const MILESTONE_STATUSES = ['PENDING', 'COMPLETED', 'SKIPPED'] as const;
|
||||
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||
|
||||
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
|
||||
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
|
||||
/**
|
||||
* A GL clearance milestone (18–23 per direction). Pre-booking milestones attach
|
||||
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
|
||||
* See §5.12, §5.16, §11.3.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'clearance_milestones' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['contractId'])
|
||||
@Index(['ownerRegion', 'status'])
|
||||
export class ClearanceMilestone extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
|
||||
contractId?: string | null;
|
||||
|
||||
@ManyToOne(() => Contract, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract | null;
|
||||
|
||||
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
|
||||
clearanceCycleId?: string | null;
|
||||
|
||||
@Column({ name: 'milestone_code', type: 'varchar', length: 64 })
|
||||
milestoneCode!: string;
|
||||
|
||||
@Column({ name: 'milestone_label', type: 'varchar', length: 255 })
|
||||
milestoneLabel!: string;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: MilestoneStatus;
|
||||
|
||||
@Column({ name: 'owner_region', type: 'varchar', length: 5, nullable: true })
|
||||
ownerRegion?: MilestoneOwnerRegion | null;
|
||||
|
||||
@Column({ name: 'triggered_by_doc', type: 'boolean', default: false })
|
||||
triggeredByDoc!: boolean;
|
||||
|
||||
@Column({ name: 'triggered_at', type: 'timestamptz', nullable: true })
|
||||
triggeredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'triggered_by_user_id', type: 'uuid', nullable: true })
|
||||
triggeredByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_APPROVAL_STEP_STATUSES = [
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'SKIPPED',
|
||||
] as const;
|
||||
export type ContractApprovalStepStatus =
|
||||
(typeof CONTRACT_APPROVAL_STEP_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_approval_steps' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractId', 'stepOrder'])
|
||||
export class ContractApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.approvalSteps, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 40 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ContractApprovalStepStatus;
|
||||
|
||||
@Column({ name: 'acted_by_staff_id', type: 'uuid', nullable: true })
|
||||
actedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'acted_at', type: 'timestamptz', nullable: true })
|
||||
actedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* What cargo sizes/commodities are in scope for a contract — NO quantities.
|
||||
* Container: one row per enabled size (20ft/40ft). Bulk: one row with a cargo
|
||||
* type. See §5.4.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_cargo_scope' })
|
||||
@Index(['contractId'])
|
||||
export class ContractCargoScope extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.cargoScope, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
/** '20ft' | '40ft'; null for bulk. */
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType, { nullable: true })
|
||||
@JoinColumn({ name: 'cargo_type_id' })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
|
||||
cargoFreeText?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* One pre-booking clearance round on a contract (Path B). GENERAL contracts run
|
||||
* many cycles; ONE_TIME uses cycle_number = 1. The booking GL creates is linked
|
||||
* back via bookingId. See §5.16.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_clearance_cycles' })
|
||||
@Index(['contractId'])
|
||||
export class ContractClearanceCycle extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.clearanceCycles, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'cycle_number', type: 'int' })
|
||||
cycleNumber!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'AWAITING_DOCUMENTS' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'now()' })
|
||||
startedAt!: Date;
|
||||
|
||||
@Column({ name: 'clearance_ready_at', type: 'timestamptz', nullable: true })
|
||||
clearanceReadyAt?: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_DOC_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
|
||||
export type ContractDocReviewStatus =
|
||||
(typeof CONTRACT_DOC_REVIEW_STATUSES)[number];
|
||||
|
||||
export const CONTRACT_DOC_UPLOADER_ROLES = ['CUSTOMER', 'GL_ET', 'GL_DJ'] as const;
|
||||
export type ContractDocUploaderRole =
|
||||
(typeof CONTRACT_DOC_UPLOADER_ROLES)[number];
|
||||
|
||||
/**
|
||||
* Per-document pre-booking clearance review on a contract (Path B). Mirrors
|
||||
* BookingDocumentReview but keyed on contract_id (+ optional clearance cycle).
|
||||
* See §5.16.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_document_review' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
export class ContractDocumentReview extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
|
||||
clearanceCycleId?: string | null;
|
||||
|
||||
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
|
||||
settingCode!: string;
|
||||
|
||||
@Column({ name: 'file_key', type: 'varchar', length: 128 })
|
||||
fileKey!: string;
|
||||
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ContractDocReviewStatus;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_by_role', type: 'varchar', length: 20, default: 'CUSTOMER' })
|
||||
uploadedByRole!: ContractDocUploaderRole;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* Frozen UNIT rates at contract submit time — one row per rate line. The booking
|
||||
* computes totals from these × entered quantities. See §5.7.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_rate_snapshots' })
|
||||
@Index(['contractId'])
|
||||
export class ContractRateSnapshot extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.rateSnapshots, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
|
||||
rateId?: string | null;
|
||||
|
||||
@Column({ name: 'rate_code', type: 'varchar', length: 64 })
|
||||
rateCode!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'unit_price', type: 'numeric', precision: 14, scale: 2 })
|
||||
unitPrice!: number;
|
||||
|
||||
/** per_container | per_ton | per_item | per_km | flat */
|
||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 32 })
|
||||
unitOfMeasure!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'is_surcharge', type: 'boolean', default: false })
|
||||
isSurcharge!: boolean;
|
||||
|
||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||
conditionalOn?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_REVIEW_NOTE_TYPES = [
|
||||
'CHANGES_REQUESTED',
|
||||
'REJECTION',
|
||||
'STAFF_NOTE',
|
||||
'CUSTOMER_NOTE',
|
||||
'AMENDMENT',
|
||||
] as const;
|
||||
export type ContractReviewNoteType =
|
||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_review_notes' })
|
||||
@Index(['contractId'])
|
||||
export class ContractReviewNote extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.reviewNotes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'note_type', type: 'varchar', length: 40 })
|
||||
noteType!: ContractReviewNoteType;
|
||||
|
||||
@Column({ name: 'body', type: 'text' })
|
||||
body!: string;
|
||||
|
||||
@Column({ name: 'author_role', type: 'varchar', length: 20, nullable: true })
|
||||
authorRole?: string | null;
|
||||
|
||||
@Column({ name: 'author_user_id', type: 'uuid', nullable: true })
|
||||
authorUserId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* An allowed origin→destination lane of a contract. Routes carry NO quantity —
|
||||
* the contract scope just lists which lanes shipments may use. See §5.3.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_routes' })
|
||||
@Index(['contractId'])
|
||||
export class ContractRoute extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.routes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
/** Road billing distance; null for rail-only. */
|
||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
km?: number | null;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_signatures' })
|
||||
@Index(['contractId'])
|
||||
export class ContractSignature extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.signatures, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'role', type: 'varchar', length: 20 })
|
||||
role!: ContractSignerRole;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 255 })
|
||||
signerDisplayName!: string;
|
||||
|
||||
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||
signatureFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
|
||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||
consentText?: string | null;
|
||||
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', default: () => 'now()' })
|
||||
signedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { ContractRoute } from './contract-route.entity';
|
||||
import { ContractCargoScope } from './contract-cargo-scope.entity';
|
||||
import { ContractRateSnapshot } from './contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './contract-signature.entity';
|
||||
import { ContractApprovalStep } from './contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './contract-review-note.entity';
|
||||
import { ContractClearanceCycle } from './contract-clearance-cycle.entity';
|
||||
|
||||
export const CONTRACT_STATUSES = [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'CONTRACT_CLOSED',
|
||||
'EXPIRED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'RENEWAL_DRAFT',
|
||||
'RENEWAL_SUBMITTED',
|
||||
'RENEWAL_PENDING_APPROVAL',
|
||||
'AMENDMENTS_PROPOSED',
|
||||
'ARCHIVED',
|
||||
] as const;
|
||||
|
||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||
|
||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
] as const;
|
||||
export type ContractClearanceStatusValue =
|
||||
(typeof CONTRACT_CLEARANCE_STATUSES)[number];
|
||||
|
||||
/** Statuses where the customer may still edit contract fields. */
|
||||
export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
|
||||
'DRAFT',
|
||||
'CHANGES_REQUESTED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The legal/commercial agreement. Defines what cargo sizes/commodities, routes,
|
||||
* and flags are in scope plus the frozen unit rates — but NO quantities. Spawns
|
||||
* shipment {@link Booking} rows via bookings.contract_id. See docs/new-doc.md §5.2.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contracts' })
|
||||
@Index(['companyId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractKind'])
|
||||
export class Contract extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
companyProfile?: CompanyProfile | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20 })
|
||||
contractKind!: ContractKindValue;
|
||||
|
||||
@Column({ name: 'renewal_of_id', type: 'uuid', nullable: true })
|
||||
renewalOfId?: string | null;
|
||||
|
||||
@ManyToOne(() => Contract, { nullable: true })
|
||||
@JoinColumn({ name: 'renewal_of_id' })
|
||||
renewalOf?: Contract | null;
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@JoinColumn({ name: 'service_type_id' })
|
||||
serviceType?: ServiceType;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20, nullable: true })
|
||||
equipmentReturn?: string | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
||||
firstMilePickupAddress?: string | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLat?: number | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLng?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLat?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
|
||||
estimatedShipmentDate?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
|
||||
contractValidityDays?: number | null;
|
||||
|
||||
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
|
||||
contractValidFrom?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
|
||||
contractValidUntil?: Date | null;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||
clearanceStatus!: string;
|
||||
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'pricing_display_mode', type: 'varchar', length: 20, default: 'UNIT_RATES', nullable: true })
|
||||
pricingDisplayMode?: string | null;
|
||||
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20, nullable: true })
|
||||
contractType?: string | null;
|
||||
|
||||
@Column({ name: 'contract_template_key', type: 'varchar', length: 128, nullable: true })
|
||||
contractTemplateKey?: string | null;
|
||||
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
@Column({ name: 'version_number', type: 'int', default: 1 })
|
||||
versionNumber!: number;
|
||||
|
||||
@Column({ name: 'financial_terms', type: 'jsonb', nullable: true })
|
||||
financialTerms?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
|
||||
approvedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
|
||||
approvedByStaffAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
|
||||
signedByDirectorId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
|
||||
signedByDirectorAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
|
||||
signedByCeoId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
|
||||
signedByCeoAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
|
||||
customerSignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||
fullyExecutedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||
lockedAt?: Date | null;
|
||||
|
||||
@OneToMany(() => ContractRoute, (r) => r.contract)
|
||||
routes?: ContractRoute[];
|
||||
|
||||
@OneToMany(() => ContractCargoScope, (c) => c.contract)
|
||||
cargoScope?: ContractCargoScope[];
|
||||
|
||||
@OneToMany(() => ContractRateSnapshot, (s) => s.contract)
|
||||
rateSnapshots?: ContractRateSnapshot[];
|
||||
|
||||
@OneToMany(() => ContractSignature, (s) => s.contract)
|
||||
signatures?: ContractSignature[];
|
||||
|
||||
@OneToMany(() => ContractApprovalStep, (s) => s.contract)
|
||||
approvalSteps?: ContractApprovalStep[];
|
||||
|
||||
@OneToMany(() => ContractReviewNote, (n) => n.contract)
|
||||
reviewNotes?: ContractReviewNote[];
|
||||
|
||||
@OneToMany(() => ContractClearanceCycle, (c) => c.contract)
|
||||
clearanceCycles?: ContractClearanceCycle[];
|
||||
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
files?: FileRecord[];
|
||||
}
|
||||
@@ -34,13 +34,8 @@ import {
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
@@ -60,24 +55,9 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
) { }
|
||||
|
||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||
private async contractPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const months = Number(setting.children?.[0]?.value);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
@@ -315,22 +295,8 @@ export class PaymentService {
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
// A general contract is paid once, up front; it does NOT enter the train
|
||||
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
||||
// opens its ordering window. Orders placed later spawn their own paid
|
||||
// child bookings that go through the normal pipeline.
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: input.bookingId } });
|
||||
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
||||
|
||||
let contractExpiresAt: Date | null = null;
|
||||
if (isGeneralContract) {
|
||||
const months = await this.contractPeriodMonths();
|
||||
contractExpiresAt = new Date(paidAt);
|
||||
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
||||
}
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
@@ -340,21 +306,11 @@ export class PaymentService {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
isGeneralContract
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
if (isGeneralContract) {
|
||||
this.logger.log(
|
||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -26,7 +26,8 @@ const DEMO_STAFF_USERS = [
|
||||
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
|
||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -238,9 +238,14 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||
},
|
||||
{
|
||||
key: "edr_global_logistics",
|
||||
name: { en: "EDR Global Logistics" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
|
||||
key: "edr_gl_ethiopia",
|
||||
name: { en: "EDR Global Logistics — Ethiopia" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia],
|
||||
},
|
||||
{
|
||||
key: "edr_gl_djibouti",
|
||||
name: { en: "EDR Global Logistics — Djibouti" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti],
|
||||
},
|
||||
{
|
||||
key: "edr_org_manager",
|
||||
|
||||
@@ -352,6 +352,80 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Contract pre-booking clearance settings (Path B) ────────────────────────
|
||||
// For customs-clearance contracts the customer uploads clearance documents on
|
||||
// the CONTRACT (before any booking exists). Same customer doc sets as the legacy
|
||||
// booking clearance, plus the GL output sets, keyed on the contract. Resolved by
|
||||
// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and
|
||||
// contract_clearance_output_{op}_container).
|
||||
const CONTRACT_CLEARANCE_ENTITY = "contract_clearance";
|
||||
|
||||
const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "contract_clearance_import_container",
|
||||
label: "Contract clearance documents (import container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_export_container",
|
||||
label: "Contract clearance documents (export container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_import_bulk",
|
||||
label: "Contract clearance documents (import bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_BULK_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_export_bulk",
|
||||
label: "Contract clearance documents (export bulk)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_BULK_FIELDS,
|
||||
},
|
||||
// GL ET output sets uploaded during pre-booking clearance.
|
||||
{
|
||||
code: "contract_clearance_output_import_container",
|
||||
label: "Contract customs output documents (import container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "contract_clearance_output_export_container",
|
||||
label: "Contract customs output documents (export container)",
|
||||
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Contract intake settings ────────────────────────────────────────────────
|
||||
// Commercial/framework documents attached at contract submission (wizard step 5),
|
||||
// distinct from the post-sign clearance docs above.
|
||||
const CONTRACT_INTAKE_ENTITY = "contract_intake";
|
||||
|
||||
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
|
||||
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
|
||||
required: false,
|
||||
}),
|
||||
clearanceField("supporting_document", "Supporting Document", 3, {
|
||||
required: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "contract_intake_documents",
|
||||
label: "Contract intake documents",
|
||||
entity: CONTRACT_INTAKE_ENTITY,
|
||||
fields: CONTRACT_INTAKE_FIELDS,
|
||||
},
|
||||
];
|
||||
|
||||
const CLEARANCE_DESCRIPTION =
|
||||
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
|
||||
|
||||
@@ -377,6 +451,16 @@ export class FileUploadSettingsSeeder {
|
||||
...s,
|
||||
description: CLEARANCE_DESCRIPTION,
|
||||
})),
|
||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||
})),
|
||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Commercial/framework documents attached at contract submission.",
|
||||
})),
|
||||
];
|
||||
|
||||
for (const documentSetting of allSettings) {
|
||||
|
||||
@@ -62,6 +62,25 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||
];
|
||||
|
||||
/**
|
||||
* Contract-phase permissions (contract–booking separation). Mirror the booking
|
||||
* approval/sign/clearance permissions but scoped to the new contracts module.
|
||||
*/
|
||||
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
||||
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
|
||||
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
|
||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
|
||||
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
|
||||
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||
];
|
||||
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||
@@ -88,6 +107,7 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO
|
||||
|
||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||
...BOOKING_PERMISSIONS,
|
||||
...CONTRACT_PERMISSIONS,
|
||||
...RULE_ENGINE_PERMISSIONS,
|
||||
];
|
||||
|
||||
@@ -114,6 +134,20 @@ export const FREIGHT_PERMS = {
|
||||
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
|
||||
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
||||
},
|
||||
contracts: {
|
||||
view: 'edr_freight_app:contracts:view',
|
||||
staffAccept: 'edr_freight_app:contracts:staff_accept',
|
||||
requestChanges: 'edr_freight_app:contracts:request_changes',
|
||||
reject: 'edr_freight_app:contracts:reject',
|
||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||
signStaff: 'edr_freight_app:contracts:sign_staff',
|
||||
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||
},
|
||||
trainScheduling: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
manage: 'edr_freight_app:train_scheduling:manage',
|
||||
@@ -146,6 +180,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
// Operations Officer: train scheduling + wagon allocation + transit/complete
|
||||
@@ -164,12 +203,17 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
ceo: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
finance: [FREIGHT_PERMS.bookings.view],
|
||||
@@ -178,11 +222,28 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
// clearance:view permission lists the clearance bookings. Reviews customer
|
||||
// clearance documents, uploads customs output documents, and finalizes the
|
||||
// clearance gate.
|
||||
globalLogistics: [
|
||||
// GL Ethiopia (edr_gl_ethiopia): reviews pre-booking clearance docs on the
|
||||
// contract, uploads ET output docs, finalizes clearance, and EXCLUSIVELY creates
|
||||
// the booking under a customs contract (Path B). Also runs post-booking ET
|
||||
// milestones + the legacy booking-clearance permissions during migration.
|
||||
glEthiopia: [
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
],
|
||||
// GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones,
|
||||
// damage reports. Read-only on the contract; no booking creation.
|
||||
glDjibouti: [
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
],
|
||||
// Marketing handles intake through contract (same as line staff here) and,
|
||||
// for non-customs bookings, reviews/finalizes the customer's clearance
|
||||
@@ -199,6 +260,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.signStaff,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
FREIGHT_PERMS.contracts.signStaff,
|
||||
],
|
||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||
} as const;
|
||||
@@ -206,5 +274,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.name.en,
|
||||
module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine',
|
||||
module: p.key.includes(':bookings:')
|
||||
? 'bookings'
|
||||
: p.key.includes(':contracts:')
|
||||
? 'contracts'
|
||||
: 'rule_engine',
|
||||
}));
|
||||
|
||||
@@ -18,7 +18,8 @@ const STAFF_USERS = [
|
||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
|
||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Boxes,
|
||||
Building2,
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
@@ -31,6 +32,12 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -97,6 +104,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Contract requests",
|
||||
href: "/dashboard/contract-requests",
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -120,6 +133,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
},
|
||||
{
|
||||
label: "Contract Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
@@ -413,6 +432,56 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Contracts (Path A/B) */}
|
||||
<Route
|
||||
path="contract-requests"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
|
||||
<ContractRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-requests/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
|
||||
<ContractRequestDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<ContractClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/:id/create-booking"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<GlCreateBookingForm />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:id/milestones"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<BookingMilestonesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Check, Circle, Clock, MinusCircle } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ClearanceMilestoneTimelineProps {
|
||||
milestones: Freight.IClearanceMilestone[];
|
||||
/** Complete a milestone by code (omit to render read-only). */
|
||||
onComplete?: (code: string, note?: string) => void;
|
||||
/** True while a complete mutation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.MilestoneStatus,
|
||||
{ color: string; label: string }
|
||||
> = {
|
||||
COMPLETED: { color: "edr-green", label: "Completed" },
|
||||
PENDING: { color: "gray", label: "Pending" },
|
||||
SKIPPED: { color: "gray", label: "Skipped" },
|
||||
};
|
||||
|
||||
/** Vertical timeline of GL clearance milestones with inline complete actions. */
|
||||
export function ClearanceMilestoneTimeline({
|
||||
milestones,
|
||||
onComplete,
|
||||
busy,
|
||||
}: ClearanceMilestoneTimelineProps) {
|
||||
const [openNote, setOpenNote] = useState<Record<string, boolean>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const nextPending = sorted.find((m) => m.status === "PENDING");
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No milestones for this shipment yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{sorted.map((m, index) => {
|
||||
const isLast = index === sorted.length - 1;
|
||||
const meta = STATUS_META[m.status];
|
||||
const isNext = nextPending?.id === m.id;
|
||||
const Icon =
|
||||
m.status === "COMPLETED"
|
||||
? Check
|
||||
: m.status === "SKIPPED"
|
||||
? MinusCircle
|
||||
: isNext
|
||||
? Clock
|
||||
: Circle;
|
||||
|
||||
return (
|
||||
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
|
||||
<ThemeIcon
|
||||
variant={m.status === "COMPLETED" ? "filled" : "light"}
|
||||
color={isNext ? "edr-green" : meta.color}
|
||||
radius="xl"
|
||||
size={30}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} />
|
||||
</ThemeIcon>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 28,
|
||||
background:
|
||||
m.status === "COMPLETED"
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box pb={isLast ? 0 : "md"} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{m.milestoneLabel}
|
||||
</Text>
|
||||
<Group gap={6} mt={2} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{m.ownerRegion ? (
|
||||
<Badge size="xs" variant="default">
|
||||
{m.ownerRegion}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{m.note ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{m.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{onComplete && m.status === "PENDING" && isNext ? (
|
||||
!openNote[m.milestoneCode] ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
) : null
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{onComplete && openNote[m.milestoneCode] && (
|
||||
<Box mt="xs">
|
||||
<Textarea
|
||||
placeholder="Optional note for this milestone…"
|
||||
value={notes[m.milestoneCode] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((n) => ({
|
||||
...n,
|
||||
[m.milestoneCode]: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
autosize
|
||||
minRows={2}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
loading={busy}
|
||||
onClick={() => {
|
||||
onComplete(
|
||||
m.milestoneCode,
|
||||
notes[m.milestoneCode]?.trim() || undefined,
|
||||
);
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Mark complete
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
PackagePlus,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateContractBooking } from "@/lib/permissions";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractActionsToolbarProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
||||
export function ContractActionsToolbar({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { status } = contract;
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [validityDays, setValidityDays] = useState<number | string>(365);
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
const canGenerate = ["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(
|
||||
status,
|
||||
);
|
||||
const canSign = ["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status);
|
||||
const canCreateBooking =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
contract.customsClearingEnabled &&
|
||||
canCreateContractBooking(user);
|
||||
|
||||
const signStaff = () =>
|
||||
mutations.signContract.mutate({
|
||||
role: "STAFF",
|
||||
signatureImageBase64: "",
|
||||
signerDisplayName:
|
||||
user?.name?.en || user?.username || user?.email || "Staff",
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => setAcceptOpen(true)}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Sparkles size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSign && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
loading={mutations.signContract.isPending}
|
||||
onClick={signStaff}
|
||||
>
|
||||
Sign as staff
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCreateBooking && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/${contract.id}/create-booking`)
|
||||
}
|
||||
>
|
||||
Create booking (GL)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canAccept &&
|
||||
!canGenerate &&
|
||||
!canSign &&
|
||||
!canCreateBooking && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Accept — sets the contract validity window */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
onClose={() => setAcceptOpen(false)}
|
||||
title="Accept contract for approval"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Set the contract validity window, then start the approval chain.
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Validity (days)"
|
||||
min={1}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={mutations.staffAccept.isPending}
|
||||
onClick={() =>
|
||||
mutations.staffAccept.mutate(Number(validityDays) || 365, {
|
||||
onSuccess: () => setAcceptOpen(false),
|
||||
})
|
||||
}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
opened={changesOpen}
|
||||
onClose={() => setChangesOpen(false)}
|
||||
title="Request changes"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="What needs to change?"
|
||||
placeholder="Describe the changes the customer must make…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={changesNote}
|
||||
onChange={(e) => setChangesNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="orange"
|
||||
disabled={!changesNote.trim()}
|
||||
loading={mutations.requestChanges.isPending}
|
||||
onClick={() =>
|
||||
mutations.requestChanges.mutate(changesNote, {
|
||||
onSuccess: () => {
|
||||
setChangesOpen(false);
|
||||
setChangesNote("");
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Reject */}
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject contract"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="Explain why this contract is rejected…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectReason.trim()}
|
||||
loading={mutations.reject.isPending}
|
||||
onClick={() =>
|
||||
mutations.reject.mutate(rejectReason, {
|
||||
onSuccess: () => {
|
||||
setRejectOpen(false);
|
||||
setRejectReason("");
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import type { ContractListRow } from "@/features/contracts/mapContractListRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContractApprovalProgressCellProps {
|
||||
row: ContractListRow;
|
||||
}
|
||||
|
||||
export function ContractApprovalProgressCell({
|
||||
row,
|
||||
}: ContractApprovalProgressCellProps) {
|
||||
const summary = formatContractApprovalProgress(row.status, row.approvalSteps);
|
||||
|
||||
return (
|
||||
<div className="min-w-[8.5rem] py-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
summary.complete
|
||||
? "text-[color:var(--freight-brand)]"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{summary.label}
|
||||
</p>
|
||||
{summary.detail ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{summary.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractApprovalStepsCardProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Approval chain with inline approve on the next pending step. */
|
||||
export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(contract.approvalSteps ?? [])].sort(
|
||||
(a, b) => a.stepOrder - b.stepOrder,
|
||||
),
|
||||
[contract.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() =>
|
||||
mutations.approveStep.mutate({
|
||||
stepId: step.id,
|
||||
requiredRole: step.requiredRole,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "edr-green"
|
||||
: step.status === "REJECTED"
|
||||
? "red"
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: isNext
|
||||
? "var(--mantine-color-gray-7)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
{step.note && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export interface ContractClearanceReviewSectionProps {
|
||||
contractId: string;
|
||||
/** Called after any review/finalize mutation so the parent can refetch. */
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
hideSummary?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.ContractDocReviewStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
APPROVED: { label: "Approved", color: "edr-green" },
|
||||
QUERIED: { label: "Queried", color: "red" },
|
||||
PENDING: { label: "Pending", color: "gray" },
|
||||
};
|
||||
|
||||
/**
|
||||
* GL-ET pre-booking clearance review for a CONTRACT (Path B): approve / query
|
||||
* each customer document, upload GL output documents and finalize once every
|
||||
* required document is approved → CLEARANCE_READY_FOR_BOOKING.
|
||||
*/
|
||||
export function ContractClearanceReviewSection({
|
||||
contractId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
queryFn: () => contractsService.getClearance(contractId),
|
||||
});
|
||||
|
||||
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
||||
useContractClearanceMutations(contractId);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "gl_et" || d.uploadedBy === "gl_dj",
|
||||
),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = customerDocs.length;
|
||||
const approved = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "APPROVED",
|
||||
).length;
|
||||
const queried = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "QUERIED",
|
||||
).length;
|
||||
const pending = total - approved - queried;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [customerDocs]);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReview = (
|
||||
fileKey: string,
|
||||
status: "APPROVED" | "QUERIED",
|
||||
note?: string,
|
||||
) =>
|
||||
reviewDocument.mutate(
|
||||
{ fileKey, status, note },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (status === "QUERIED")
|
||||
setOpenQuery((o) => ({ ...o, [fileKey]: false }));
|
||||
onChanged?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Customer documents"
|
||||
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||
extra={
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
{!hideSummary && stats.total > 0 && (
|
||||
<Box>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb={6}
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="gray" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer documents are required for this contract.
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||
}
|
||||
onNote={(v) =>
|
||||
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||
}
|
||||
onApprove={() => handleReview(doc.fileKey, "APPROVED")}
|
||||
onQuery={() =>
|
||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||
}
|
||||
busy={reviewDocument.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{glDocs.length > 0 && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="GL output documents"
|
||||
subtitle="Upload IM4/IM5/EX3/EX8/T1 and other cleared paperwork."
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text fz="13px" c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
disabled={Object.keys(outputFiles).length === 0}
|
||||
loading={uploadOutputDocuments.isPending}
|
||||
onClick={() =>
|
||||
uploadOutputDocuments.mutate(outputFiles, {
|
||||
onSuccess: () => {
|
||||
setOutputFiles({});
|
||||
onChanged?.();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Upload output documents
|
||||
</Button>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{finalizeClearance.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{finalizeClearance.error instanceof Error
|
||||
? finalizeClearance.error.message
|
||||
: "Could not finalize clearance."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
<FileCheck2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved — you can finalize."
|
||||
: "Approve every required document to unlock finalization."}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeClearance.isPending}
|
||||
onClick={() =>
|
||||
finalizeClearance.mutate(undefined, {
|
||||
onSuccess: () => onChanged?.(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="12.5px" c="edr-text" fw={600}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const hasFile = !!doc.file;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor:
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={hasFile ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={40}
|
||||
>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="edr-muted" truncate>
|
||||
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile && (
|
||||
<Tooltip label="Open document">
|
||||
<Button
|
||||
component="a"
|
||||
href={doc.file!.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ExternalLink size={13} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{status === "QUERIED" && doc.note && (
|
||||
<Alert
|
||||
mt="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="12.5px" c="red.9">
|
||||
{doc.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFile && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-red-0)",
|
||||
border: "1px solid var(--mantine-color-red-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6}>
|
||||
<MessageSquareWarning
|
||||
size={14}
|
||||
color="var(--mantine-color-red-7)"
|
||||
/>
|
||||
<Text fz="12.5px" fw={700} c="red.8">
|
||||
Describe the problem for the customer
|
||||
</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
placeholder="e.g. The commercial invoice is missing the HS code."
|
||||
value={note}
|
||||
onChange={(e) => onNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
loading={busy}
|
||||
disabled={!note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Send query to customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Repeat } from "lucide-react";
|
||||
|
||||
import {
|
||||
CONTRACT_STATUS_COLOR,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
interface ContractStatusBadgeProps {
|
||||
status: string;
|
||||
/** When the contract is a renewal of a prior one, show a sibling badge. */
|
||||
isRenewal?: boolean;
|
||||
}
|
||||
|
||||
export function ContractStatusBadge({
|
||||
status,
|
||||
isRenewal,
|
||||
}: ContractStatusBadgeProps) {
|
||||
const style = CONTRACT_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "gray",
|
||||
};
|
||||
const color = CONTRACT_STATUS_COLOR[status] ?? "gray";
|
||||
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
title={style.label}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!isRenewal) return statusBadge;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{statusBadge}
|
||||
<Badge
|
||||
color="indigo"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={<Repeat size={12} />}
|
||||
title="Renewal of a prior contract"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Renewal
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
||||
active: <Truck size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
};
|
||||
|
||||
interface ContractStatusTabsProps {
|
||||
active: ContractStatusTabKey;
|
||||
onChange: (tab: ContractStatusTabKey) => void;
|
||||
counts?: Partial<Record<ContractStatusTabKey, number>>;
|
||||
}
|
||||
|
||||
export function ContractStatusTabs({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
}: ContractStatusTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
||||
{CONTRACT_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
leftSection={TAB_ICONS[tab.key]}
|
||||
size={"sm"}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ContractStatusTabKey };
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||
|
||||
import {
|
||||
CONTRACT_WORKFLOW_STAGES,
|
||||
getContractWorkflowStageIndex,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
BRAND_GREEN,
|
||||
detailStyles,
|
||||
} from "@/components/bookings/detail/booking-detail.styles";
|
||||
|
||||
const STAGE_ICONS: LucideIcon[] = [
|
||||
FileText,
|
||||
FileSignature,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Check,
|
||||
];
|
||||
|
||||
interface ContractWorkflowStepperProps {
|
||||
status: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ContractWorkflowStepper({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
}: ContractWorkflowStepperProps) {
|
||||
const currentStage = getContractWorkflowStageIndex(status);
|
||||
const isTerminal = currentStage < 0;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Workflow} title="Workflow progress">
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||
{CONTRACT_WORKFLOW_STAGES.map((stage, index) => {
|
||||
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||
const isComplete = !isTerminal && index < currentStage;
|
||||
const isActive = !isTerminal && index === currentStage;
|
||||
const isLast = index === CONTRACT_WORKFLOW_STAGES.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={stage.label}
|
||||
style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}
|
||||
>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-2)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check size={16} strokeWidth={3} />
|
||||
) : (
|
||||
<Icon size={15} />
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{stage.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 8,
|
||||
marginBottom: 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={
|
||||
isTerminal
|
||||
? detailStyles.statusBannerTerminal
|
||||
: detailStyles.statusBanner
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Container as ContainerIcon,
|
||||
FileText,
|
||||
Package,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | string;
|
||||
}
|
||||
|
||||
interface ContainerLineDraft {
|
||||
containerSize: string;
|
||||
hazardousQuantity: number | string;
|
||||
reeferQuantity: number | string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
interface BulkLineDraft {
|
||||
cargoTypeId: string;
|
||||
cargoWeightTons: number | string;
|
||||
itemCount: number | string;
|
||||
hazardousQuantity: number | string;
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
}
|
||||
|
||||
export default function GlCreateBookingForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||
|
||||
const isContainer = contract?.freightType === "CONTAINER";
|
||||
const routes = useMemo(
|
||||
() =>
|
||||
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[contract?.routes],
|
||||
);
|
||||
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
|
||||
|
||||
const containerSizes = useMemo(() => {
|
||||
const sizes = new Set<string>();
|
||||
(contract?.cargoScope ?? []).forEach((s) => {
|
||||
if (s.containerSize) sizes.add(s.containerSize);
|
||||
});
|
||||
return [...sizes];
|
||||
}, [contract?.cargoScope]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="50vh">
|
||||
<Loader color="gray" />
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Container line helpers ──
|
||||
const addContainerLine = () =>
|
||||
setContainerLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
containerSize: containerSizes[0] ?? "20ft",
|
||||
hazardousQuantity: "",
|
||||
reeferQuantity: "",
|
||||
units: [emptyUnit()],
|
||||
},
|
||||
]);
|
||||
const removeContainerLine = (idx: number) =>
|
||||
setContainerLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
const addUnit = (lineIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: [...containerLines[lineIdx].units, emptyUnit()],
|
||||
});
|
||||
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx),
|
||||
});
|
||||
const patchUnit = (
|
||||
lineIdx: number,
|
||||
unitIdx: number,
|
||||
patch: Partial<UnitDraft>,
|
||||
) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.map((u, i) =>
|
||||
i === unitIdx ? { ...u, ...patch } : u,
|
||||
),
|
||||
});
|
||||
|
||||
// ── Bulk line helpers ──
|
||||
const addBulkLine = () =>
|
||||
setBulkLines((prev) => [
|
||||
...prev,
|
||||
{ cargoTypeId: "", cargoWeightTons: "", itemCount: "", hazardousQuantity: "" },
|
||||
]);
|
||||
const removeBulkLine = (idx: number) =>
|
||||
setBulkLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
|
||||
setBulkLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(scheduledDate) &&
|
||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||
(isContainer ? containerLines.length > 0 : bulkLines.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!scheduledDate) return;
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
...(contractRouteId ? { contractRouteId } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
payload.containers = containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
...(l.reeferQuantity !== ""
|
||||
? { reeferQuantity: Number(l.reeferQuantity) }
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
payload.bulkLines = bulkLines.map((l) => ({
|
||||
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
|
||||
...(l.cargoWeightTons !== ""
|
||||
? { cargoWeightTons: Number(l.cargoWeightTons) }
|
||||
: {}),
|
||||
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
mutations.createBooking.mutate(payload, {
|
||||
onSuccess: (booking) =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Create booking (GL)"
|
||||
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
||||
backTo={`/dashboard/contracts/clearance/${contract.id}`}
|
||||
breadcrumbs={[
|
||||
{ label: "Contract Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{
|
||||
label: contract.reference,
|
||||
href: `/dashboard/contracts/clearance/${contract.id}`,
|
||||
},
|
||||
{ label: "Create booking" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={FileText} title="Schedule">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Scheduled date"
|
||||
type="date"
|
||||
description="Binding shipment day"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
{needsRouteSelect && (
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Route"
|
||||
placeholder="Select contract route"
|
||||
value={contractRouteId}
|
||||
onChange={setContractRouteId}
|
||||
data={routes.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"} → ${
|
||||
r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"
|
||||
}`,
|
||||
}))}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
|
||||
{isContainer ? (
|
||||
<SectionCard
|
||||
icon={ContainerIcon}
|
||||
title="Containers"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addContainerLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one container line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{containerLines.map((line, lineIdx) => (
|
||||
<Box
|
||||
key={lineIdx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {lineIdx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeContainerLine(lineIdx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<Select
|
||||
label="Container size"
|
||||
value={line.containerSize}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, {
|
||||
containerSize: v ?? line.containerSize,
|
||||
})
|
||||
}
|
||||
data={
|
||||
containerSizes.length > 0
|
||||
? containerSizes
|
||||
: ["20ft", "40ft"]
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { reeferQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Divider
|
||||
my="sm"
|
||||
label={`${line.units.length} container unit${
|
||||
line.units.length === 1 ? "" : "s"
|
||||
}`}
|
||||
labelPosition="left"
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Grid key={unitIdx} gap="xs" align="flex-end">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container no." : undefined}
|
||||
placeholder="MSKU1234567"
|
||||
value={unit.containerNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 3 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal no." : undefined}
|
||||
placeholder="Optional"
|
||||
value={unit.sealNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 5, sm: 3 }}>
|
||||
<NumberInput
|
||||
label={unitIdx === 0 ? "VGM (t)" : undefined}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={unit.vgmTons}
|
||||
onChange={(v) =>
|
||||
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 1, sm: 2 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={line.units.length === 1}
|
||||
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||
aria-label="Remove unit"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
))}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() => addUnit(lineIdx)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Add container unit
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={Package}
|
||||
title="Bulk cargo"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addBulkLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{bulkLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one bulk line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{bulkLines.map((line, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {idx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeBulkLine(idx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Cargo type id"
|
||||
placeholder="Optional"
|
||||
value={line.cargoTypeId}
|
||||
onChange={(e) =>
|
||||
patchBulk(idx, { cargoTypeId: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Weight (tons)"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={line.cargoWeightTons}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { cargoWeightTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Item count"
|
||||
min={0}
|
||||
value={line.itemCount}
|
||||
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={FileText} title="Notes">
|
||||
<Textarea
|
||||
placeholder="Internal GL notes (optional)"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!canSubmit}
|
||||
loading={mutations.createBooking.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import type { CompanyListFilter } from "@/types/customer";
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
@@ -45,6 +46,21 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
ROOT: ["contracts"] as const,
|
||||
list: (filter?: ContractListFilter) =>
|
||||
["contracts", "list", filter ?? {}] as const,
|
||||
listSummary: (filter?: ContractListFilter) =>
|
||||
["contracts", "list-summary", filter ?? {}] as const,
|
||||
byId: (id: string) => ["contracts", "detail", id] as const,
|
||||
clearance: (id: string) => ["contracts", "clearance", id] as const,
|
||||
clearanceQueue: (region?: string) =>
|
||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||
bookingMilestones: (bookingId: string) =>
|
||||
["contracts", "booking-milestones", bookingId] as const,
|
||||
},
|
||||
|
||||
BOOKING_ORDERS: {
|
||||
ROOT: ["booking-orders"] as const,
|
||||
byContract: (contractBookingId: string) =>
|
||||
|
||||
@@ -124,6 +124,33 @@ export const URL_CONSTANTS = {
|
||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
BASE: "/contracts",
|
||||
LIST_SUMMARY: "/contracts/list-summary",
|
||||
BY_ID: (id: string) => `/contracts/${id}`,
|
||||
STAFF_ACCEPT: (id: string) => `/contracts/${id}/staff/accept`,
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/contracts/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/milestones`,
|
||||
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
|
||||
`/contracts/bookings/${bookingId}/milestones/${code}/complete`,
|
||||
},
|
||||
|
||||
OTP: {
|
||||
SEND: "/api/otp/send",
|
||||
VERIFY: "/api/otp/verify",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ApprovalProgressSummary {
|
||||
label: string;
|
||||
detail: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function nextPending(
|
||||
steps: Freight.IContractApprovalStep[],
|
||||
): Freight.IContractApprovalStep | undefined {
|
||||
return steps.find((s) => s.status === "PENDING");
|
||||
}
|
||||
|
||||
/** Compact approval-chain summary for contract list rows (mirrors bookings). */
|
||||
export function formatContractApprovalProgress(
|
||||
status: string,
|
||||
steps?: Freight.IContractApprovalStep[] | null,
|
||||
): ApprovalProgressSummary {
|
||||
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (status === "SUBMITTED") {
|
||||
return {
|
||||
label: "Awaiting accept",
|
||||
detail: "Staff must accept intake",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status === "PENDING_APPROVAL" ||
|
||||
status === "APPROVED_PENDING_SIGNATURE"
|
||||
) {
|
||||
return {
|
||||
label: "No steps",
|
||||
detail: "Approval chain not started",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"CONTRACT_CLOSED",
|
||||
].includes(status)
|
||||
) {
|
||||
return {
|
||||
label: "Approved",
|
||||
detail: "Internal approval complete",
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
return { label: "—", detail: "", complete: false };
|
||||
}
|
||||
|
||||
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||
const total = sorted.length;
|
||||
const next = nextPending(sorted);
|
||||
|
||||
if (!next && approved === total) {
|
||||
return {
|
||||
label: `${approved}/${total} done`,
|
||||
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||
complete: approved === total,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { ContractStatus } from "@edr/types";
|
||||
|
||||
export interface StatusStyle {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Tailwind chip styling per contract status (mirrors booking-status.config). */
|
||||
export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
SUBMITTED: {
|
||||
label: "Submitted",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||
label: "Price Confirm",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
label: "Changes Requested",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
label: "Pending Approval",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
APPROVED: {
|
||||
label: "Approved",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
label: "Pending Signature",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
label: "Contract Ready",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
label: "Customer Signed",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
label: "Fully Executed",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
},
|
||||
CONTRACT_ACTIVE: {
|
||||
label: "Active",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Awaiting Documents",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
CLEARANCE_UNDER_REVIEW: {
|
||||
label: "Clearance Review",
|
||||
color: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
},
|
||||
CLEARANCE_READY_FOR_BOOKING: {
|
||||
label: "Ready for Booking",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: {
|
||||
label: "Shipment in Progress",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
label: "Closed",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
EXPIRED: {
|
||||
label: "Expired",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
REJECTED: {
|
||||
label: "Rejected",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
RENEWAL_DRAFT: {
|
||||
label: "Renewal Draft",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
RENEWAL_SUBMITTED: {
|
||||
label: "Renewal Submitted",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
RENEWAL_PENDING_APPROVAL: {
|
||||
label: "Renewal Approval",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
AMENDMENTS_PROPOSED: {
|
||||
label: "Amendments Proposed",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: "Archived",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
};
|
||||
|
||||
/** Mantine palette colour per contract status (mirrors BookingStatusBadge map). */
|
||||
export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
SUBMITTED: "yellow",
|
||||
PRICE_CHANGED_PENDING_CONFIRM: "yellow",
|
||||
CHANGES_REQUESTED: "orange",
|
||||
PENDING_APPROVAL: "yellow",
|
||||
APPROVED: "edr-green",
|
||||
APPROVED_PENDING_SIGNATURE: "cyan",
|
||||
CONTRACT_READY: "indigo",
|
||||
SIGNED_CUSTOMER: "cyan",
|
||||
FULLY_EXECUTED: "indigo",
|
||||
CONTRACT_ACTIVE: "edr-green",
|
||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
|
||||
CONTRACT_CLOSED: "gray",
|
||||
EXPIRED: "red",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
RENEWAL_DRAFT: "gray",
|
||||
RENEWAL_SUBMITTED: "yellow",
|
||||
RENEWAL_PENDING_APPROVAL: "yellow",
|
||||
AMENDMENTS_PROPOSED: "orange",
|
||||
ARCHIVED: "gray",
|
||||
};
|
||||
|
||||
export interface StatusMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
stage: number;
|
||||
}
|
||||
|
||||
export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
DRAFT: {
|
||||
title: "Draft",
|
||||
description: "Contract is being prepared by the customer.",
|
||||
color: "text-slate-500",
|
||||
stage: 0,
|
||||
},
|
||||
SUBMITTED: {
|
||||
title: "Submitted",
|
||||
description: "Awaiting staff review.",
|
||||
color: "text-amber-600",
|
||||
stage: 0,
|
||||
},
|
||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||
title: "Price Confirm",
|
||||
description: "Awaiting customer confirmation of revised unit rates.",
|
||||
color: "text-amber-600",
|
||||
stage: 0,
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
title: "Changes Requested",
|
||||
description: "Returned to customer for updates.",
|
||||
color: "text-orange-600",
|
||||
stage: 0,
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
title: "Pending Approval",
|
||||
description: "Moving through the internal approval chain.",
|
||||
color: "text-amber-600",
|
||||
stage: 1,
|
||||
},
|
||||
APPROVED: {
|
||||
title: "Approved",
|
||||
description: "Approved; contract document can be generated.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 1,
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
title: "Pending Signature",
|
||||
description: "Awaiting director or CEO signature steps.",
|
||||
color: "text-sky-600",
|
||||
stage: 1,
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
title: "Contract Ready",
|
||||
description: "Contract generated; awaiting customer signature.",
|
||||
color: "text-indigo-600",
|
||||
stage: 2,
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
title: "Customer Signed",
|
||||
description: "Awaiting contract execution.",
|
||||
color: "text-sky-600",
|
||||
stage: 2,
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Fully Executed",
|
||||
description: "One-time contract executed; transport-only path.",
|
||||
color: "text-indigo-600",
|
||||
stage: 3,
|
||||
},
|
||||
CONTRACT_ACTIVE: {
|
||||
title: "Active",
|
||||
description: "General contract active over its validity window.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
title: "Awaiting Documents",
|
||||
description: "Customer is uploading pre-booking clearance documents.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
CLEARANCE_UNDER_REVIEW: {
|
||||
title: "Clearance Review",
|
||||
description: "Global Logistics ET is reviewing clearance documents.",
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
CLEARANCE_READY_FOR_BOOKING: {
|
||||
title: "Ready for Booking",
|
||||
description: "Clearance complete — GL can create the shipment booking.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 4,
|
||||
},
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: {
|
||||
title: "Shipment in Progress",
|
||||
description: "A shipment booking is active under this contract.",
|
||||
color: "text-sky-600",
|
||||
stage: 4,
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
title: "Closed",
|
||||
description: "Contract fulfilled and closed.",
|
||||
color: "text-slate-500",
|
||||
stage: 5,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Expired",
|
||||
description: "Validity window elapsed.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Rejected",
|
||||
description: "Contract was rejected.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
CANCELLED: {
|
||||
title: "Cancelled",
|
||||
description: "Contract was cancelled.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
};
|
||||
|
||||
export const CONTRACT_LIST_TABS = [
|
||||
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
||||
{
|
||||
key: "intake",
|
||||
label: "Submitted",
|
||||
statuses: ["SUBMITTED", "PRICE_CHANGED_PENDING_CONFIRM", "CHANGES_REQUESTED"],
|
||||
},
|
||||
{
|
||||
key: "in_approval",
|
||||
label: "In approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
|
||||
},
|
||||
{
|
||||
key: "approved_contract",
|
||||
label: "Contract & signature",
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
key: "clearance",
|
||||
label: "Clearance",
|
||||
statuses: [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "Active",
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "closed",
|
||||
label: "Closed",
|
||||
statuses: ["CONTRACT_CLOSED", "EXPIRED", "REJECTED", "CANCELLED"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ContractStatusTabKey = (typeof CONTRACT_LIST_TABS)[number]["key"];
|
||||
|
||||
export const CONTRACT_WORKFLOW_STAGES = [
|
||||
{
|
||||
label: "Submission",
|
||||
statuses: [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
|
||||
},
|
||||
{
|
||||
label: "Signature",
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
label: "Clearance",
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipment",
|
||||
statuses: ["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"],
|
||||
},
|
||||
{ label: "Done", statuses: ["CONTRACT_CLOSED"] },
|
||||
] as const;
|
||||
|
||||
export function getContractStatusMeta(status: ContractStatus | string): StatusMeta {
|
||||
return (
|
||||
CONTRACT_STATUS_META[status] ?? {
|
||||
title: status,
|
||||
description: "",
|
||||
color: "text-muted-foreground",
|
||||
stage: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getContractWorkflowStageIndex(
|
||||
status: ContractStatus | string,
|
||||
): number {
|
||||
const meta = getContractStatusMeta(status);
|
||||
if (meta.stage < 0) return -1;
|
||||
return meta.stage;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ContractListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
approvalSteps?: Freight.IContractApprovalStep[];
|
||||
customerLabel: string;
|
||||
status: string;
|
||||
contractKind: Freight.ContractKind;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
validityDays?: number | null;
|
||||
isRenewal: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
export function toContractListRow(contract: Freight.IContract): ContractListRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
approvalSteps: contract.approvalSteps,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
status: contract.status,
|
||||
contractKind: contract.contractKind,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
validFrom: contract.contractValidFrom,
|
||||
validUntil: contract.contractValidUntil,
|
||||
validityDays: contract.contractValidityDays,
|
||||
isRenewal: Boolean(contract.renewalOfId),
|
||||
createdAt: contract.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import {
|
||||
contractsService,
|
||||
type ContractListFilter,
|
||||
type SignContractPayload,
|
||||
} from "@/services/contracts.service";
|
||||
|
||||
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id) }),
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }),
|
||||
]).then(() => undefined);
|
||||
}
|
||||
|
||||
export function useContractList(filter?: ContractListFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.list(filter),
|
||||
queryFn: () => contractsService.list(filter),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractListSummary(
|
||||
filter?: ContractListFilter,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.listSummary(filter),
|
||||
queryFn: () => contractsService.getListSummary(filter),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractDetail(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.byId(id ?? ""),
|
||||
queryFn: () => contractsService.getById(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractClearanceQueue(region = "ET", enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue(region),
|
||||
queryFn: () => contractsService.getClearanceQueue(region),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractMilestones(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
|
||||
queryFn: () => contractsService.listMilestonesForContract(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingMilestones(bookingId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),
|
||||
queryFn: () => contractsService.listMilestonesForBooking(bookingId!),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractMutations(contractId: string) {
|
||||
const qc = useQueryClient();
|
||||
const onSuccess = (data: { id: string }, message: string) => {
|
||||
toast.success(message);
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
};
|
||||
|
||||
const staffAccept = useMutation({
|
||||
mutationFn: (validityDays: number) =>
|
||||
contractsService.staffAccept(contractId, validityDays),
|
||||
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
||||
onError: () => toast.error("Failed to accept contract"),
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract rejected"),
|
||||
onError: () => toast.error("Failed to reject contract"),
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||
onSuccess: (data) => onSuccess(data, "Approval step completed"),
|
||||
onError: () => toast.error("Failed to approve step"),
|
||||
});
|
||||
|
||||
const generateContract = useMutation({
|
||||
mutationFn: () => contractsService.generateContract(contractId),
|
||||
onSuccess: (data) => onSuccess(data, "Contract generated"),
|
||||
onError: () => toast.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"),
|
||||
});
|
||||
|
||||
const createBooking = useMutation({
|
||||
mutationFn: (payload: Freight.CreateBookingUnderContractDto) =>
|
||||
contractsService.createBookingUnderContract(contractId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Booking created under contract");
|
||||
void invalidateContractDetail(qc, contractId);
|
||||
},
|
||||
onError: () => toast.error("Failed to create booking"),
|
||||
});
|
||||
|
||||
const isPending =
|
||||
staffAccept.isPending ||
|
||||
requestChanges.isPending ||
|
||||
reject.isPending ||
|
||||
approveStep.isPending ||
|
||||
generateContract.isPending ||
|
||||
signContract.isPending ||
|
||||
createBooking.isPending;
|
||||
|
||||
return {
|
||||
staffAccept,
|
||||
requestChanges,
|
||||
reject,
|
||||
approveStep,
|
||||
generateContract,
|
||||
signContract,
|
||||
createBooking,
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pre-booking clearance mutations (GL ET) keyed on a contract. */
|
||||
export function useContractClearanceMutations(contractId: string) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", "clearance-queue"],
|
||||
});
|
||||
void invalidateContractDetail(qc, contractId);
|
||||
};
|
||||
|
||||
const reviewDocument = useMutation({
|
||||
mutationFn: (p: {
|
||||
fileKey: string;
|
||||
status: "APPROVED" | "QUERIED";
|
||||
note?: string;
|
||||
}) => contractsService.reviewClearanceDocument(contractId, p),
|
||||
onSuccess: (_d, p) => {
|
||||
toast.success(
|
||||
p.status === "APPROVED"
|
||||
? "Document approved"
|
||||
: "Query sent to customer",
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
});
|
||||
|
||||
const uploadOutputDocuments = useMutation({
|
||||
mutationFn: (files: Record<string, File | null>) =>
|
||||
contractsService.uploadClearanceOutput(contractId, files),
|
||||
onSuccess: () => {
|
||||
toast.success("Output documents uploaded");
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Upload failed"),
|
||||
});
|
||||
|
||||
const finalizeClearance = useMutation({
|
||||
mutationFn: () => contractsService.finalizeClearance(contractId),
|
||||
onSuccess: () => {
|
||||
toast.success("Clearance finalized — ready for booking");
|
||||
refresh();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Could not finalize clearance",
|
||||
),
|
||||
});
|
||||
|
||||
return { reviewDocument, uploadOutputDocuments, finalizeClearance };
|
||||
}
|
||||
|
||||
/** Complete a post-booking GL milestone. */
|
||||
export function useCompleteMilestone(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ code, note }: { code: string; note?: string }) =>
|
||||
contractsService.completeMilestone(bookingId, code, note),
|
||||
onSuccess: () => {
|
||||
toast.success("Milestone completed");
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to complete milestone"),
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,20 @@ export const FREIGHT_PERMS = {
|
||||
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
||||
reject: "edr_freight_app:contracts:reject",
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: "edr_freight_app:contracts:sign_staff",
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
@@ -83,6 +97,24 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.view);
|
||||
}
|
||||
|
||||
/** Can review the GL Ethiopia pre-booking contract clearance queue (Path B). */
|
||||
export function canReviewContractClearance(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
}
|
||||
|
||||
/** GL Ethiopia: can create a booking under a cleared contract (Path B). */
|
||||
export function canCreateContractBooking(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
}
|
||||
|
||||
/** Can see/manage the customs document-clearance queue (Global Logistics). */
|
||||
export function canViewClearance(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Flag, ListChecks } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useCompleteMilestone,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingMilestonesPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const { data: milestones, isLoading } = useBookingMilestones(id);
|
||||
const complete = useCompleteMilestone(id ?? "");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = milestones ?? [];
|
||||
const total = list.length;
|
||||
const completed = list.filter((m) => m.status === "COMPLETED").length;
|
||||
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||
return { total, completed, pct };
|
||||
}, [milestones]);
|
||||
|
||||
const reference = booking?.reference ?? "Shipment";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`${reference} milestones`}
|
||||
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
|
||||
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: reference },
|
||||
{ label: "Milestones" },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ListChecks size={13} />}
|
||||
>
|
||||
{stats.completed}/{stats.total} done
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<SectionCard icon={Flag} title="Clearance milestones">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={milestones ?? []}
|
||||
busy={complete.isPending}
|
||||
onComplete={(code, note) =>
|
||||
complete.mutate({ code, note })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard icon={ListChecks} title="Progress" accent="edr-green">
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
complete
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Box w="100%">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Milestones
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.completed}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateContractBooking } from "@/lib/permissions";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
);
|
||||
const total = docs.length;
|
||||
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
|
||||
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
|
||||
const pending = total - approved - queried;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
const canBook =
|
||||
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
canCreateContractBooking(user);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80} gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !clearance) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Contract Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn’t load this contract’s clearance.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Contract Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
action={
|
||||
canBook ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/${id}/create-booking`)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection contractId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceHero({
|
||||
contract,
|
||||
stats,
|
||||
}: {
|
||||
contract: ReturnType<typeof useContractDetail>["data"];
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
}) {
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const last = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
last?.destinationYard?.label ??
|
||||
last?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||
{contract?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{direction}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={8} mt={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Document review
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressStat({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700} fz={18} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="11px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type Region = "ET" | "DJ";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
contractKind: string;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
contractKind: contract.contractKind,
|
||||
};
|
||||
}
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [region, setRegion] = useState<Region>("ET");
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(region);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{r.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: () => (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 56,
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract Clearance"
|
||||
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} awaiting review
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
value: counts.import,
|
||||
icon: Truck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Export",
|
||||
value: counts.export,
|
||||
icon: ShipWheel,
|
||||
color: "gray",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, or route…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={region}
|
||||
onChange={(v) => {
|
||||
setRegion(v as Region);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{ value: "ET", label: "Ethiopia" },
|
||||
{ value: "DJ", label: "Djibouti" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as ViewMode)}
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<TableIcon size={15} />
|
||||
<Box visibleFrom="sm">Table</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<LayoutGrid size={15} />
|
||||
<Box visibleFrom="sm">Cards</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
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>
|
||||
) : (
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box px="md" py="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts awaiting review.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
pb="md"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="md"
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer", transition: "all 120ms ease" }}
|
||||
className="hover:border-edr-green-4 hover:shadow-md"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="edr-text" truncate>
|
||||
{row.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={11} className="shrink-0 opacity-70" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{row.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-edr-card-6)",
|
||||
border: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" justify="center">
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" mt="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<DirectionIcon direction={row.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{row.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Box as BoxIcon,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Flame,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
Snowflake,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: contract,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="gray" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading contract…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileText size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Contract not found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to contract requests
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const statusMeta = getContractStatusMeta(contract.status);
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const showApprovalCard =
|
||||
contract.status === "PENDING_APPROVAL" ||
|
||||
contract.status === "APPROVED" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Contract requests", href: "/dashboard/contract-requests" },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Contract reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{contract.reference}
|
||||
</Title>
|
||||
<ContractStatusBadge
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Created ${formatDate(contract.createdAt)}`}
|
||||
/>
|
||||
{contract.contractValidUntil ? (
|
||||
<MetaItem
|
||||
icon={CalendarClock}
|
||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ContractWorkflowStepper
|
||||
status={contract.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No routes on this contract.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{routes.map((r) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.originYard?.label ??
|
||||
r.originYard?.code ??
|
||||
"Origin"}
|
||||
</Text>
|
||||
<ArrowRight
|
||||
size={15}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.km != null ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{r.km} km
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Package} title="Cargo scope">
|
||||
<Group gap="sm" mb="md">
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.tradeDirection}
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.freightType}
|
||||
</Badge>
|
||||
{contract.isHazardous ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={12} />}
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="cyan"
|
||||
radius="sm"
|
||||
leftSection={<Snowflake size={12} />}
|
||||
>
|
||||
Reefer
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{contract.pricingBreakdown?.lineItems?.length ? (
|
||||
<SectionCard icon={Receipt} title="Unit rates">
|
||||
<Stack gap="xs">
|
||||
{contract.pricingBreakdown.lineItems.map((li) => (
|
||||
<Group
|
||||
key={li.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{li.label}
|
||||
{li.containerSize ? ` · ${li.containerSize}` : ""}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
|
||||
{li.unit}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{contract.contractSummary ? (
|
||||
<SectionCard icon={FileText} title="Contract summary">
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{contract.contractSummary}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<ContractActionsToolbar
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
<ContractApprovalStepsCard
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={600} c="dark">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/components/contracts/ContractStatusTabs";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import {
|
||||
useContractList,
|
||||
useContractListSummary,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useContractList(filter);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useContractListSummary(filter);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toContractListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(c) =>
|
||||
c.reference.toLowerCase().includes(q) ||
|
||||
c.customerLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data?.items, query]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const metrics = summary?.metrics;
|
||||
const tabCounts = summary?.tabs;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
}, [refetch, refetchSummary]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ContractListRow) => {
|
||||
navigate(`/dashboard/contract-requests/${row.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{c.validUntil
|
||||
? `Until ${formatDate(c.validUntil)}`
|
||||
: c.validityDays
|
||||
? `${c.validityDays} days`
|
||||
: "—"}
|
||||
</span>
|
||||
{c.validFrom ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
From {formatDate(c.validFrom)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.contractKind === "GENERAL";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{isGeneral ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract requests"
|
||||
subtitle="Review, approve, and execute freight contract requests."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: metrics?.inQueue ?? 0,
|
||||
icon: LayoutList,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: metrics?.needsAction ?? 0,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: metrics?.urgent ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: "red",
|
||||
},
|
||||
{
|
||||
label: "Closed",
|
||||
value: tabCounts?.closed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ContractStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts match this view.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
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>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
const C = URL_CONSTANTS.CONTRACTS;
|
||||
|
||||
export interface ContractListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs. */
|
||||
statuses?: string;
|
||||
/** Tab key for React Query cache (not sent to API). */
|
||||
tab?: string;
|
||||
companyId?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
contractKind?: string;
|
||||
paymentCurrency?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface PaginatedContracts {
|
||||
items: Freight.IContract[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryMetrics {
|
||||
inQueue: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryTabs {
|
||||
all: number;
|
||||
intake: number;
|
||||
in_approval: number;
|
||||
approved_contract: number;
|
||||
clearance: number;
|
||||
active: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummary {
|
||||
metrics: ContractListSummaryMetrics;
|
||||
tabs: ContractListSummaryTabs;
|
||||
}
|
||||
|
||||
export interface ContractView {
|
||||
contractId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
templateKey: string;
|
||||
title: string;
|
||||
html: string;
|
||||
canSignCustomer: boolean;
|
||||
canSignStaff: boolean;
|
||||
hasContractDocument: boolean;
|
||||
signatures: Array<{
|
||||
role: string;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}>;
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
||||
const response = await client.post<T>(url, body ?? {});
|
||||
return unwrap(response.data);
|
||||
}
|
||||
|
||||
function buildListParams(filter?: ContractListFilter) {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (filter) {
|
||||
if (filter.statuses) params.statuses = filter.statuses;
|
||||
else if (filter.status) params.status = filter.status;
|
||||
if (filter.page != null) params.page = filter.page;
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.contractKind) params.contractKind = filter.contractKind;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export const contractsService = {
|
||||
getListSummary: async (
|
||||
filter?: ContractListFilter,
|
||||
): Promise<ContractListSummary> => {
|
||||
const response = await client.get<ContractListSummary>(C.LIST_SUMMARY, {
|
||||
params: buildListParams(filter),
|
||||
});
|
||||
return unwrap(response.data) as ContractListSummary;
|
||||
},
|
||||
|
||||
list: async (filter?: ContractListFilter): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.BASE, {
|
||||
params: buildListParams(filter),
|
||||
});
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
// ── Staff review ──
|
||||
staffAccept: (id: string, validityDays: number) =>
|
||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
|
||||
|
||||
requestChanges: (id: string, note: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
||||
|
||||
reject: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||
|
||||
approveStep: ({
|
||||
id,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
|
||||
requiredRole,
|
||||
}),
|
||||
|
||||
// ── Contract document ──
|
||||
generateContract: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
||||
|
||||
getContractView: async (id: string): Promise<ContractView> => {
|
||||
const response = await client.get<ContractView>(C.CONTRACT_VIEW(id));
|
||||
return unwrap(response.data) as ContractView;
|
||||
},
|
||||
|
||||
signContract: (id: string, payload: SignContractPayload) =>
|
||||
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
||||
|
||||
// ── Pre-booking clearance (Path B — GL ET) ──
|
||||
getClearanceQueue: async (
|
||||
region = "ET",
|
||||
): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE, {
|
||||
params: { region },
|
||||
});
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id));
|
||||
return unwrap(response.data) as Freight.ContractClearanceView;
|
||||
},
|
||||
|
||||
reviewClearanceDocument: (
|
||||
id: string,
|
||||
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||
) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
||||
|
||||
uploadClearanceOutput: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.CLEARANCE_OUTPUT_DOCUMENTS(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
finalizeClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||
|
||||
// ── Booking under contract (GL ET — Path B) ──
|
||||
createBookingUnderContract: (
|
||||
id: string,
|
||||
payload: Freight.CreateBookingUnderContractDto,
|
||||
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
||||
|
||||
// ── Clearance milestones ──
|
||||
listMilestonesForContract: async (
|
||||
id: string,
|
||||
): Promise<Freight.IClearanceMilestone[]> => {
|
||||
const response = await client.get(C.MILESTONES(id));
|
||||
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
||||
},
|
||||
|
||||
listMilestonesForBooking: async (
|
||||
bookingId: string,
|
||||
): Promise<Freight.IClearanceMilestone[]> => {
|
||||
const response = await client.get(C.BOOKING_MILESTONES(bookingId));
|
||||
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
||||
},
|
||||
|
||||
completeMilestone: (bookingId: string, code: string, note?: string) =>
|
||||
postContract<Freight.IClearanceMilestone>(
|
||||
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
|
||||
{ note },
|
||||
),
|
||||
};
|
||||
@@ -37,9 +37,11 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
import NewContractPage from "./pages/contracts/NewContractPage";
|
||||
import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
@@ -182,7 +184,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
icon: <CalendarCheck size={18} />,
|
||||
},
|
||||
{
|
||||
label: "General Contracts",
|
||||
label: "Contracts",
|
||||
href: "/contracts",
|
||||
icon: <Layers size={18} />,
|
||||
},
|
||||
@@ -258,7 +260,11 @@ const App = () => {
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||
{/* Contract creation replaces the legacy booking wizard. */}
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
@@ -266,6 +272,15 @@ const App = () => {
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/clearance"
|
||||
element={<ContractClearanceFlow />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
|
||||
@@ -108,6 +108,27 @@ export const URL_CONSTANTS = {
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
BASE: "/api/contracts",
|
||||
MY: "/api/contracts/my",
|
||||
BY_ID: (id: string) => `/api/contracts/${id}`,
|
||||
DOCUMENTS: (id: string) => `/api/contracts/${id}/documents`,
|
||||
GENERATE_PRICE: (id: string) => `/api/contracts/${id}/generate-price`,
|
||||
SUBMIT: (id: string) => `/api/contracts/${id}/submit`,
|
||||
CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`,
|
||||
CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
|
||||
CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`,
|
||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||
`/api/contracts/${id}/clearance/documents`,
|
||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||
},
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||
import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
|
||||
|
||||
const GREEN = "#0A6F4D";
|
||||
|
||||
type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
||||
if (doc.reviewStatus === "APPROVED") {
|
||||
return (
|
||||
<Group gap={6} c={GREEN}>
|
||||
<CheckCircle2 size={15} />
|
||||
<Text fz="12px" fw={600} c={GREEN}>
|
||||
Approved
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.reviewStatus === "QUERIED") {
|
||||
return (
|
||||
<Group gap={6} c="#C0392B">
|
||||
<AlertCircle size={15} />
|
||||
<Text fz="12px" fw={600} c="#C0392B">
|
||||
Queried
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.file) {
|
||||
return (
|
||||
<Group gap={6} c="#2E5B96">
|
||||
<Clock size={15} />
|
||||
<Text fz="12px" fw={600} c="#2E5B96">
|
||||
Pending review
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
||||
Not uploaded
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path B customer clearance upload on the CONTRACT (doc §8.3). Mirrors the
|
||||
* booking `ClearanceFlow` but targets the contract clearance endpoints — no
|
||||
* booking exists yet. The customer uploads required documents (and duty/tax
|
||||
* slips when advised), Global Logistics reviews them, and after approval GL
|
||||
* creates the booking on the customer's behalf.
|
||||
*/
|
||||
export default function ContractClearanceFlow() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||
|
||||
const { data: contract } = useQuery(
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
const clearanceQuery = useQuery(
|
||||
api.contracts.getClearance.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
}),
|
||||
);
|
||||
const clearance = clearanceQuery.data;
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
setAdHoc([]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.getClearance.queryKey({ id: id! }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||
[customerDocs, pending],
|
||||
);
|
||||
|
||||
const hasStagedFiles =
|
||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
||||
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStagedFiles && missingRequired.length === 0
|
||||
: hasStagedFiles;
|
||||
|
||||
const stagePending = (fileKey: string, file: File) =>
|
||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
||||
|
||||
const submitDocuments = () => {
|
||||
const files: Record<string, File | null> = { ...pending };
|
||||
adHoc.forEach((row, i) => {
|
||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||
});
|
||||
if (Object.keys(files).length === 0) return;
|
||||
uploadMutation.mutate({ id: id!, files });
|
||||
};
|
||||
|
||||
if (clearanceQuery.isLoading) {
|
||||
return (
|
||||
<Center mih={400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px={8}
|
||||
onClick={() => navigate(`/contracts/${id}`)}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Button>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={48} radius="lg" variant="light" color="edr-green">
|
||||
<Upload size={23} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap={10} align="center">
|
||||
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
|
||||
Clearance documents
|
||||
</Title>
|
||||
{contract && <ContractStatusBadge status={contract.status} />}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{contract?.reference ?? ""} · Cycle{" "}
|
||||
{clearance?.cycleNumber ?? 1}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
||||
<Stack gap={0}>
|
||||
{isReady ? (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Your clearance documents are approved. Global Logistics will
|
||||
create your booking — you will be notified when payment is due.
|
||||
</Alert>
|
||||
) : isUnderReview ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<Clock size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Global Logistics is reviewing your documents. Only re-upload the
|
||||
documents flagged with a query below — approved documents stay as
|
||||
they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Upload every required clearance document (marked *) below to start
|
||||
the review. Global Logistics will clear your shipment and create
|
||||
the booking for you.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required:{" "}
|
||||
{missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Required customer documents */}
|
||||
<Stack gap={10}>
|
||||
{customerDocs.map((doc) => (
|
||||
<Box
|
||||
key={doc.fileKey}
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box c="#2E5B96">
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
{doc.file && (
|
||||
<Text fz="12px" c="dimmed" truncate>
|
||||
{doc.file.name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<StatusPill doc={doc} />
|
||||
{doc.file && (
|
||||
<IconSquare
|
||||
href={doc.file.url}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
||||
<Text fz="12px" c="#C0392B" mt={6}>
|
||||
Query: {doc.note}
|
||||
</Text>
|
||||
)}
|
||||
{pending[doc.fileKey] && (
|
||||
<Text fz="12px" c={GREEN} mt={6}>
|
||||
Ready to upload: {pending[doc.fileKey].name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{customerDocs.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No clearance documents are configured for this contract yet.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* GL output documents (read-only). */}
|
||||
{glDocs.length > 0 && (
|
||||
<>
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||
Customs output documents
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group
|
||||
key={doc.fileKey}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 10 }}
|
||||
>
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
</Text>
|
||||
{doc.file ? (
|
||||
<IconSquare
|
||||
href={doc.file.url}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Pending
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ad-hoc / additional documents. */}
|
||||
{canUpload && (
|
||||
<Box mt="lg">
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<Text fz="12.5px" fw={700} c="#10202F">
|
||||
Additional documents
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() =>
|
||||
setAdHoc((r) => [...r, { name: "", file: null }])
|
||||
}
|
||||
>
|
||||
Add document
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
{adHoc.map((row, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Document name"
|
||||
value={row.name}
|
||||
onChange={(e) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) =>
|
||||
j === i
|
||||
? { ...r, name: e.currentTarget.value }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||
)
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} variant="default" radius="md">
|
||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mt="md"
|
||||
>
|
||||
{uploadMutation.error instanceof Error
|
||||
? uploadMutation.error.message
|
||||
: "Upload failed. Please try again."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canSubmit}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={submitDocuments}
|
||||
>
|
||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -9,87 +9,107 @@ import {
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Flame,
|
||||
Inbox,
|
||||
Layers,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
Package,
|
||||
PackagePlus,
|
||||
Ship,
|
||||
Snowflake,
|
||||
Upload,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { api } from "@/services/api";
|
||||
import { PayNowButton } from "../bookings/payments/PayNowButton";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import {
|
||||
BORDER,
|
||||
ContractStatusBadge,
|
||||
formatQuantity,
|
||||
GREEN,
|
||||
INK,
|
||||
MetaItem,
|
||||
MUTED,
|
||||
StatCard,
|
||||
} from "./contract-ui";
|
||||
import { PlaceOrderDialog } from "./PlaceOrderDialog";
|
||||
|
||||
// Statuses where Path A customers may create a shipment booking themselves.
|
||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||
// Statuses where Path B customers upload clearance docs on the contract.
|
||||
const PATH_B_CLEARANCE = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
export default function ContractDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [orderOpen, setOrderOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: contract,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery(api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }));
|
||||
} = useQuery(
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
const { data: pool } = useQuery({
|
||||
...api.bookingOrders.pool.queryOptions({
|
||||
input: { contractBookingId: id! },
|
||||
// Shipments booked under this contract.
|
||||
const { data: bookingsPage } = useQuery({
|
||||
...api.bookings.list.queryOptions({
|
||||
input: { page: 1, pageSize: 50, sortBy: "createdAt", sortOrder: "DESC" },
|
||||
}),
|
||||
enabled: !!id && contract?.status !== "DRAFT",
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: orders } = useQuery({
|
||||
...api.bookingOrders.listByContract.queryOptions({
|
||||
input: { contractBookingId: id! },
|
||||
}),
|
||||
enabled: !!id && contract?.status !== "DRAFT",
|
||||
const contractBookings = useMemo(
|
||||
() =>
|
||||
(bookingsPage?.items ?? []).filter(
|
||||
(b) => (b as { contractId?: string }).contractId === id,
|
||||
),
|
||||
[bookingsPage, id],
|
||||
);
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
role: "CUSTOMER";
|
||||
signatureImageBase64: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}) => contractsService.signContract(id!, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Contract signed successfully");
|
||||
setSignOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
});
|
||||
|
||||
// Multi-route contracts return one line per contracted route; single-route
|
||||
// contracts return []. Drives the Routes section + the per-route order flow.
|
||||
const { data: routeLines } = useQuery({
|
||||
...api.bookingOrders.routes.queryOptions({
|
||||
input: { contractBookingId: id! },
|
||||
}),
|
||||
enabled: !!id && contract?.status !== "DRAFT",
|
||||
});
|
||||
|
||||
const poolLines = pool ?? [];
|
||||
|
||||
// Overall utilization across every pool line — drives the header ring + stat.
|
||||
// Declared before the early returns so hook order stays stable across renders.
|
||||
const totals = useMemo(() => {
|
||||
const contracted = poolLines.reduce(
|
||||
(s, l) => s + (l.contractedQuantity || 0),
|
||||
0,
|
||||
);
|
||||
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
|
||||
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
|
||||
return { contracted, ordered, pct };
|
||||
}, [poolLines]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih={400} p="xl">
|
||||
@@ -119,9 +139,28 @@ export default function ContractDetailPage() {
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
const isActive = contract.status === "CONTRACT_ACTIVE";
|
||||
const awaitingPayment = contract.status === "FULLY_EXECUTED";
|
||||
const showPool = contract.status !== "DRAFT";
|
||||
const isGeneral = contract.contractKind === "GENERAL";
|
||||
const routes = contract.routes ?? [];
|
||||
const pricing = contract.pricingBreakdown;
|
||||
|
||||
const canSign = contract.status === "CONTRACT_READY";
|
||||
const customsPath = contract.customsClearingEnabled;
|
||||
// Path A — transport only, customer may book a shipment directly.
|
||||
const canBookShipment =
|
||||
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
||||
// Path B — customs clearance, customer uploads clearance documents.
|
||||
const canUploadClearance =
|
||||
customsPath && PATH_B_CLEARANCE.includes(contract.status);
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim() || !signatureData) return;
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: signatureData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -139,7 +178,12 @@ export default function ContractDetailPage() {
|
||||
<ArrowLeft size={18} />
|
||||
</Button>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
|
||||
<ThemeIcon
|
||||
size={48}
|
||||
radius="lg"
|
||||
variant="light"
|
||||
color={isGeneral ? "violet" : "edr-green"}
|
||||
>
|
||||
<Layers size={23} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
@@ -150,7 +194,8 @@ export default function ContractDetailPage() {
|
||||
<ContractStatusBadge status={contract.status} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
General contract · {isContainer ? "Containerised" : "Bulk"} ·{" "}
|
||||
{isGeneral ? "General contract" : "One-time contract"} ·{" "}
|
||||
{isContainer ? "Containerised" : "Bulk"} ·{" "}
|
||||
{contract.tradeDirection ?? "—"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -158,113 +203,260 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
{awaitingPayment && (
|
||||
<PayNowButton booking={contract} label="Pay & activate" size="sm" />
|
||||
{canSign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() => {
|
||||
setSignerName("");
|
||||
setSignatureData(null);
|
||||
setSignOpen(true);
|
||||
}}
|
||||
>
|
||||
Sign contract
|
||||
</Button>
|
||||
)}
|
||||
{isActive && (
|
||||
{canBookShipment && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => setOrderOpen(true)}
|
||||
onClick={() => navigate(`/contracts/${contract.id}/bookings/new`)}
|
||||
>
|
||||
Place order
|
||||
New shipment booking
|
||||
</Button>
|
||||
)}
|
||||
{canUploadClearance && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
|
||||
>
|
||||
Upload clearance documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Key facts strip */}
|
||||
<Paper withBorder radius={20} p="md" style={{ borderColor: BORDER }}>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 5 }} spacing="lg">
|
||||
<KeyFact
|
||||
icon={<Layers size={17} />}
|
||||
label="Kind"
|
||||
value={isGeneral ? "General" : "One-Time"}
|
||||
/>
|
||||
<KeyFact
|
||||
icon={<Package size={17} />}
|
||||
label="Cargo"
|
||||
value={isContainer ? "Container" : "Bulk"}
|
||||
/>
|
||||
<KeyFact
|
||||
icon={<MapPin size={17} />}
|
||||
label="Routes"
|
||||
value={String(routes.length || 1)}
|
||||
/>
|
||||
<KeyFact
|
||||
icon={<Ship size={17} />}
|
||||
label="Trade"
|
||||
value={contract.tradeDirection ?? "—"}
|
||||
/>
|
||||
<KeyFact
|
||||
icon={<CalendarClock size={17} />}
|
||||
label="Valid until"
|
||||
value={
|
||||
contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil).toLocaleDateString()
|
||||
: "Not active yet"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Summary meta */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group gap={48} wrap="wrap">
|
||||
<MetaItem
|
||||
label="Route"
|
||||
label="Primary route"
|
||||
icon={<MapPin size={15} color={MUTED} />}
|
||||
value={`${contract.originYard?.label ?? "—"} → ${contract.destinationYard?.label ?? "—"}`}
|
||||
value={`${routes[0]?.originYard?.label ?? "—"} → ${
|
||||
routes[0]?.destinationYard?.label ?? "—"
|
||||
}`}
|
||||
/>
|
||||
<MetaItem
|
||||
label="Ordering until"
|
||||
label="Estimated shipment"
|
||||
icon={<CalendarClock size={15} color={MUTED} />}
|
||||
value={
|
||||
contract.expiresAt
|
||||
? new Date(contract.expiresAt).toLocaleDateString()
|
||||
: "Not active yet"
|
||||
contract.estimatedShipmentDate
|
||||
? new Date(
|
||||
contract.estimatedShipmentDate,
|
||||
).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label="Trade direction"
|
||||
icon={<Ship size={15} color={MUTED} />}
|
||||
value={contract.tradeDirection ?? "—"}
|
||||
label="Customs clearance"
|
||||
icon={<FileText size={15} color={MUTED} />}
|
||||
value={customsPath ? "Included (Global Logistics)" : "Not included"}
|
||||
/>
|
||||
<MetaItem
|
||||
label="Payment currency"
|
||||
value={contract.paymentCurrency ?? "—"}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Stat strip */}
|
||||
{showPool && (
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Orders placed"
|
||||
value={orders?.length ?? 0}
|
||||
icon={PackageCheck}
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
label="Utilization"
|
||||
hint="of reserved quantity"
|
||||
value={`${totals.pct}%`}
|
||||
icon={PackageCheck}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ordering until"
|
||||
value={
|
||||
contract.expiresAt
|
||||
? new Date(contract.expiresAt).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
icon={CalendarClock}
|
||||
color="edr-accent"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Shipments"
|
||||
hint="booked under this contract"
|
||||
value={contractBookings.length}
|
||||
icon={Package}
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
label="Routes covered"
|
||||
value={routes.length || 1}
|
||||
icon={MapPin}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Valid until"
|
||||
value={
|
||||
contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
icon={CalendarClock}
|
||||
color="edr-accent"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Path B notice */}
|
||||
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#CDEBDD", background: "#F6FBF8" }}
|
||||
>
|
||||
<Group gap={12} align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
||||
<Upload size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
Customs clearance shipment
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Contracted routes — every origin/destination pair the contract covers,
|
||||
with its own remaining pool. Orders draw down one route at a time. */}
|
||||
{showPool && routeLines && routeLines.length > 0 && (
|
||||
{/* Unit-rate schedule */}
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group gap={8} mb="md">
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Unit rates
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mb="md" mt={-6}>
|
||||
Per-unit rates frozen at submission. The final amount on each shipment
|
||||
is computed from the quantities you ship.
|
||||
</Text>
|
||||
{pricing && pricing.lineItems.length > 0 ? (
|
||||
<Stack gap={10}>
|
||||
{pricing.lineItems.map((item) => (
|
||||
<Group
|
||||
key={item.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.containerSize && (
|
||||
<Text fz={12} c="dimmed">
|
||||
{item.containerSize}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
||||
{item.unitPrice.toLocaleString()} {pricing.currency}{" "}
|
||||
<Text span fz={12} fw={600} c="dimmed">
|
||||
/ {formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text fz={13} c="dimmed">
|
||||
No pricing schedule available yet.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Routes + cargo scope */}
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group gap={8} mb="lg">
|
||||
<Group gap={8} mb="md">
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Contracted routes
|
||||
Routes
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="violet" radius="sm">
|
||||
{routeLines.length}
|
||||
{routes.length || 1}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mb="md" mt={-8}>
|
||||
Lanes this contract covers. Orders draw from the shared pool below —
|
||||
pick a lane per order for scheduling and routing.
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{routeLines.map((route) => (
|
||||
{routes.length === 0 && (
|
||||
<Text fz={13} c="dimmed">
|
||||
No routes recorded.
|
||||
</Text>
|
||||
)}
|
||||
{routes.map((route) => (
|
||||
<Group
|
||||
key={route.routeLineId}
|
||||
key={route.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<MapPin size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{route.originYardName ?? route.originYardId} →{" "}
|
||||
{route.destinationYardName ?? route.destinationYardId}
|
||||
{route.originYard?.label ?? route.originYardId} →{" "}
|
||||
{route.destinationYard?.label ?? route.destinationYardId}
|
||||
</Text>
|
||||
{route.km != null && (
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
<Text fz={12} c="dimmed">
|
||||
{route.km} km
|
||||
</Text>
|
||||
)}
|
||||
@@ -274,160 +466,164 @@ export default function ContractDetailPage() {
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drawdown pool */}
|
||||
{showPool && (
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
|
||||
<Box>
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Contracted quantity
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
How much of this contract has been ordered versus what remains.
|
||||
</Text>
|
||||
</Box>
|
||||
{totals.contracted > 0 && (
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={[{ value: totals.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={800} style={{ color: INK }}>
|
||||
{totals.pct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{poolLines.length === 0 && (
|
||||
<Text fw={700} fz={16} style={{ color: INK }} mb="md">
|
||||
Cargo scope
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{(contract.cargoScope ?? []).map((scope) => (
|
||||
<Group
|
||||
key={scope.id}
|
||||
gap={12}
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={34}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={isContainer ? "edr-green" : "orange"}
|
||||
>
|
||||
{isContainer ? <Package size={16} /> : <Weight size={16} />}
|
||||
</ThemeIcon>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{scope.containerSize ??
|
||||
scope.cargoFreeText ??
|
||||
"Bulk commodity"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{(contract.cargoScope ?? []).length === 0 && (
|
||||
<Text fz={13} c="dimmed">
|
||||
No quantity pool available.
|
||||
No cargo scope recorded.
|
||||
</Text>
|
||||
)}
|
||||
{poolLines.map((line, i) => {
|
||||
const pct =
|
||||
line.contractedQuantity > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(line.orderedQuantity / line.contractedQuantity) * 100,
|
||||
)
|
||||
: 0;
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
const depleted = line.remainingQuantity <= 0;
|
||||
return (
|
||||
<div key={line.containerTypeId ?? `bulk-${i}`}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={8} align="center">
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
{depleted && (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Fully ordered
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text
|
||||
span
|
||||
fw={700}
|
||||
style={{ color: depleted ? MUTED : GREEN }}
|
||||
>
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
</Text>{" "}
|
||||
remaining of{" "}
|
||||
{formatQuantity(
|
||||
line.contractedQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
<Group gap={10} mt={4}>
|
||||
{contract.isHazardous && (
|
||||
<Badge
|
||||
leftSection={<Flame size={12} />}
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="sm"
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
)}
|
||||
{contract.isReefer && (
|
||||
<Badge
|
||||
leftSection={<Snowflake size={12} />}
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
>
|
||||
Refrigerated
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Signatures */}
|
||||
{(contract.signatures ?? []).length > 0 && (
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Text fw={700} fz={16} style={{ color: INK }} mb="md">
|
||||
Signatures
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{(contract.signatures ?? []).map((sig) => (
|
||||
<Group
|
||||
key={sig.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={34}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{sig.signerDisplayName}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
color={depleted ? "gray" : "edr-green"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Text fz={12} c="dimmed">
|
||||
{sig.role}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Text fz={12} c="dimmed">
|
||||
{new Date(sig.signedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Orders */}
|
||||
{/* Shipments under this contract */}
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Orders
|
||||
Shipments
|
||||
</Text>
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{orders?.length ?? 0}
|
||||
{contractBookings.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!orders || orders.length === 0 ? (
|
||||
{contractBookings.length === 0 ? (
|
||||
<Stack align="center" gap={8} py="xl">
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz={13} c="dimmed" ta="center" maw={360}>
|
||||
{isActive
|
||||
? "No orders yet. Use “Place order” to draw down from this contract."
|
||||
: "Orders can be placed once the contract is active (paid)."}
|
||||
<Text fz={13} c="dimmed" ta="center" maw={380}>
|
||||
{canBookShipment
|
||||
? "No shipments yet. Use “New shipment booking” to ship against this contract."
|
||||
: customsPath
|
||||
? "No shipments yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
|
||||
: "Shipments appear here once the contract is fully executed."}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{orders.map((order) => (
|
||||
{contractBookings.map((booking) => (
|
||||
<Group
|
||||
key={order.id}
|
||||
key={booking.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${BORDER}`,
|
||||
}}
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}`, cursor: "pointer" }}
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="violet">
|
||||
<PackageCheck size={18} />
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="violet"
|
||||
>
|
||||
<Package size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{order.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship {new Date(order.scheduledDate).toLocaleDateString()}
|
||||
{" · "}
|
||||
{order.lines
|
||||
.map((l) => {
|
||||
const qty = Number(l.quantity);
|
||||
const label = Number.isInteger(qty)
|
||||
? `${qty}`
|
||||
: qty.toFixed(2);
|
||||
return `${label}${
|
||||
l.containerTypeName ? ` ${l.containerTypeName}` : ""
|
||||
}`;
|
||||
})
|
||||
.join(", ")}
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.scheduledDate && (
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship{" "}
|
||||
{new Date(booking.scheduledDate).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<ContractStatusBadge status={order.status} />
|
||||
<ContractStatusBadge status={booking.status} />
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -435,13 +631,89 @@ export default function ContractDetailPage() {
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<PlaceOrderDialog
|
||||
opened={orderOpen}
|
||||
onClose={() => setOrderOpen(false)}
|
||||
contract={contract}
|
||||
pool={poolLines}
|
||||
onPlaced={() => setOrderOpen(false)}
|
||||
/>
|
||||
{/* Sign modal */}
|
||||
<Modal
|
||||
opened={signOpen}
|
||||
onClose={() => setSignOpen(false)}
|
||||
title={<Text fw={700}>Sign contract</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{contract.reference} — your signature will be stored securely and
|
||||
applied to the contract document.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Full name"
|
||||
placeholder="Your full name"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setSignOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<FileSignature size={15} />}
|
||||
disabled={
|
||||
signMutation.isPending || !signatureData || !signerName.trim()
|
||||
}
|
||||
loading={signMutation.isPending}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
Confirm signature
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyFact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
flexShrink: 0,
|
||||
background: "#F1F6FA",
|
||||
color: "#0A6F4D",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box miw={0}>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,14 +18,16 @@ import {
|
||||
CheckCircle2,
|
||||
FileStack,
|
||||
Layers,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Timer,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -33,30 +35,41 @@ import {
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
|
||||
import { BORDER, ContractStatusBadge, INK, StatCard } from "./contract-ui";
|
||||
|
||||
function primaryRoute(contract: Freight.IContract) {
|
||||
const route = contract.routes?.[0];
|
||||
return {
|
||||
origin: route?.originYard?.label ?? "—",
|
||||
destination: route?.destinationYard?.label ?? "—",
|
||||
count: contract.routes?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
const hasExtraFilters = !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const hasExtraFilters =
|
||||
!!freightFilter || !!kindFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setFreightFilter(null);
|
||||
setKindFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
bookingType: "GENERAL_CONTRACT",
|
||||
contractKind: kindFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
@@ -66,6 +79,7 @@ export default function ContractsList() {
|
||||
sortOrder: "DESC",
|
||||
}),
|
||||
[
|
||||
kindFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
@@ -75,61 +89,72 @@ export default function ContractsList() {
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
api.contracts.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
if (!query.trim()) return items;
|
||||
const q = query.toLowerCase();
|
||||
return items.filter(
|
||||
(b) =>
|
||||
b.reference?.toLowerCase().includes(q) ||
|
||||
b.originYard?.label?.toLowerCase().includes(q) ||
|
||||
b.destinationYard?.label?.toLowerCase().includes(q),
|
||||
);
|
||||
return items.filter((c) => {
|
||||
const { origin, destination } = primaryRoute(c);
|
||||
return (
|
||||
c.reference?.toLowerCase().includes(q) ||
|
||||
origin.toLowerCase().includes(q) ||
|
||||
destination.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
const active = items.filter((b) => b.status === "CONTRACT_ACTIVE").length;
|
||||
const pending = items.filter((b) =>
|
||||
const active = items.filter((c) =>
|
||||
["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||
c.status,
|
||||
),
|
||||
).length;
|
||||
const pending = items.filter((c) =>
|
||||
[
|
||||
"SUBMITTED",
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(b.status),
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
].includes(c.status),
|
||||
).length;
|
||||
const total = data?.meta?.total ?? items.length;
|
||||
return { active, pending, total };
|
||||
}, [data]);
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
const columns: ColumnDef<Freight.IContract>[] = [
|
||||
{
|
||||
id: "reference",
|
||||
header: () => <ColHeader label="Contract" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const c = row.original;
|
||||
const isGeneral = c.contractKind === "GENERAL";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="violet"
|
||||
color={isGeneral ? "violet" : "edr-green"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Layers size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{b.reference}
|
||||
{c.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{b.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
|
||||
{isGeneral ? "General" : "One-Time"} ·{" "}
|
||||
{c.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -139,37 +164,59 @@ export default function ContractsList() {
|
||||
{
|
||||
id: "cargo",
|
||||
header: () => <ColHeader label="Cargo" />,
|
||||
cell: ({ row }) => <CargoModeCell booking={row.original} />,
|
||||
cell: ({ row }) => {
|
||||
const isContainer = row.original.freightType === "CONTAINER";
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<ThemeIcon
|
||||
size={28}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={isContainer ? "edr-green" : "orange"}
|
||||
>
|
||||
{isContainer ? <Package size={15} /> : <Weight size={15} />}
|
||||
</ThemeIcon>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <ColHeader label="Route" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const { origin, destination, count } = primaryRoute(row.original);
|
||||
return (
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{b.originYard?.label ?? "—"}{" "}
|
||||
{origin}{" "}
|
||||
<Text span c="dimmed">
|
||||
→
|
||||
</Text>{" "}
|
||||
{b.destinationYard?.label ?? "—"}
|
||||
{destination}
|
||||
{count > 1 && (
|
||||
<Text span c="dimmed" fz={12}>
|
||||
{" "}
|
||||
+{count - 1}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
header: () => <ColHeader label="Ordering Until" />,
|
||||
id: "validUntil",
|
||||
header: () => <ColHeader label="Valid Until" />,
|
||||
cell: ({ row }) => {
|
||||
const exp = row.original.expiresAt;
|
||||
const until = row.original.contractValidUntil;
|
||||
return (
|
||||
<Text fz={13} c={exp ? undefined : "dimmed"} style={{ color: exp ? INK : undefined }}>
|
||||
{exp ? new Date(exp).toLocaleDateString() : "—"}
|
||||
<Text
|
||||
fz={13}
|
||||
c={until ? undefined : "dimmed"}
|
||||
style={{ color: until ? INK : undefined }}
|
||||
>
|
||||
{until ? new Date(until).toLocaleDateString() : "—"}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
@@ -195,12 +242,17 @@ export default function ContractsList() {
|
||||
<Layers size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
General Contracts
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Contracts
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4} maw={520}>
|
||||
Reserve a quantity once, then place orders against it until the
|
||||
contract runs out or its window closes.
|
||||
Your freight agreements — one-time and general. Sign a contract,
|
||||
then ship against it over its validity window.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -209,7 +261,7 @@ export default function ContractsList() {
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
|
||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
||||
>
|
||||
New Contract
|
||||
</Button>
|
||||
@@ -219,14 +271,14 @@ export default function ContractsList() {
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Active"
|
||||
hint="accepting orders"
|
||||
hint="ready to ship"
|
||||
value={stats.active}
|
||||
icon={CheckCircle2}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="In progress"
|
||||
hint="setup / signing"
|
||||
hint="setup / signing / clearance"
|
||||
value={stats.pending}
|
||||
icon={Timer}
|
||||
color="edr-accent"
|
||||
@@ -251,6 +303,24 @@ export default function ContractsList() {
|
||||
styles={{ input: { height: 42 } }}
|
||||
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any kind"
|
||||
data={[
|
||||
{ value: "ONE_TIME", label: "One-Time" },
|
||||
{ value: "GENERAL", label: "General" },
|
||||
]}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 140 }}
|
||||
styles={{ input: { height: 42 } }}
|
||||
aria-label="Filter by contract kind"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
@@ -314,7 +384,7 @@ export default function ContractsList() {
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/contracts/${(row as Freight.IBooking).id}`)
|
||||
navigate(`/contracts/${(row as Freight.IContract).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
@@ -329,7 +399,7 @@ export default function ContractsList() {
|
||||
pageCount,
|
||||
}}
|
||||
footer={DataTableFooter}
|
||||
emptyMessage="No general contracts yet. Create one from New Booking → General Contract."
|
||||
emptyMessage="No contracts yet. Create one from New Contract."
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
import { api } from "@/services/api";
|
||||
import { Freight } from "@edr/types";
|
||||
import type {
|
||||
CreateContractPayload,
|
||||
ContractDocuments as ServiceContractDocuments,
|
||||
GenerateContractPriceResponse,
|
||||
SubmitContractResponse,
|
||||
} from "@/services/contracts.service";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Send,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
CONTRACT_STEPS,
|
||||
ContractFormInputValues,
|
||||
contractFormSchema,
|
||||
contractStepFields,
|
||||
initialContractFormValues,
|
||||
type ContractDocuments,
|
||||
type ContractFormValues,
|
||||
type OperationType,
|
||||
} from "./new-contract-form/schema";
|
||||
import {
|
||||
allowedOperationsForProfiles,
|
||||
getRouteDirection,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
} from "./new-contract-form/helpers";
|
||||
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
||||
import {
|
||||
clearContractDraft,
|
||||
useContractDraft,
|
||||
} from "./new-contract-form/useContractDraft";
|
||||
import {
|
||||
Step0OperationType,
|
||||
Step1ContractType,
|
||||
Step2ServiceType,
|
||||
Step3CargoScope,
|
||||
Step4Route,
|
||||
Step8Review,
|
||||
StepDocuments,
|
||||
} from "./new-contract-form/steps";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
|
||||
type PriceModalMode = "submit" | "draft";
|
||||
|
||||
export default function NewContractPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(0);
|
||||
const auth = useAuth();
|
||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||
api.bookings.referenceData.queryOptions(),
|
||||
);
|
||||
|
||||
// Contract creation is gated on profile approval, same as bookings.
|
||||
if (!auth.isPending && auth.company && !auth.canBook) {
|
||||
return <Navigate to="/contracts" replace />;
|
||||
}
|
||||
|
||||
if (!auth.isPending && !auth.company) {
|
||||
return (
|
||||
<GateNotice
|
||||
title="Complete Your Company Setup"
|
||||
body="You need to complete your company onboarding before you can create contracts."
|
||||
actionLabel="Go to Onboarding"
|
||||
onAction={() => navigate("/settings")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isPending && auth.companyStatus === "pending") {
|
||||
return (
|
||||
<GateNotice
|
||||
title="Awaiting Approval"
|
||||
body="Your company is awaiting EDR approval. Creating contracts is disabled until your company has been approved."
|
||||
actionLabel="Back to Contracts"
|
||||
onAction={() => navigate("/contracts")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const [pricingData, setPricingData] =
|
||||
useState<GenerateContractPriceResponse | null>(null);
|
||||
const [priceContractId, setPriceContractId] = useState<string | null>(null);
|
||||
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
||||
null,
|
||||
);
|
||||
const [priceChangeResult, setPriceChangeResult] =
|
||||
useState<SubmitContractResponse | null>(null);
|
||||
|
||||
const persistAndPriceMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
mode,
|
||||
existingContractId,
|
||||
}: {
|
||||
payload: CreateContractPayload;
|
||||
mode: PriceModalMode;
|
||||
existingContractId: string | null;
|
||||
}) => {
|
||||
const documents = (form.getValues("documents") ??
|
||||
{}) as ServiceContractDocuments;
|
||||
let contractId = existingContractId;
|
||||
|
||||
if (contractId) {
|
||||
await api.contracts.update.call({
|
||||
id: contractId,
|
||||
dto: payload,
|
||||
documents,
|
||||
});
|
||||
} else {
|
||||
const contract = await api.contracts.create.call({
|
||||
payload,
|
||||
documents,
|
||||
});
|
||||
contractId = contract.id;
|
||||
}
|
||||
|
||||
const pricing = await api.contracts.generatePrice.call({ id: contractId });
|
||||
return { contractId, pricing, mode };
|
||||
},
|
||||
onSuccess: ({ contractId, pricing, mode }) => {
|
||||
setPriceContractId(contractId);
|
||||
setPricingData(pricing);
|
||||
setPriceModalMode(mode);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceContractId) throw new Error("No contract to confirm");
|
||||
return api.contracts.submit.call({ id: priceContractId });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.priceChanged) {
|
||||
setPriceChangeResult(result);
|
||||
return;
|
||||
}
|
||||
clearContractDraft();
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
navigate(`/contracts/${priceContractId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmSubmitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceContractId) throw new Error("No contract to confirm");
|
||||
return api.contracts.confirmSubmit.call({ id: priceContractId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearContractDraft();
|
||||
setPriceChangeResult(null);
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
navigate(`/contracts/${priceContractId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceContractId) throw new Error("No contract to discard");
|
||||
return api.contracts.remove.call({ id: priceContractId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearContractDraft();
|
||||
setPriceModalMode(null);
|
||||
setPriceContractId(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
navigate("/contracts");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<ContractFormInputValues, any, ContractFormValues>({
|
||||
defaultValues: initialContractFormValues,
|
||||
resolver: zodResolver(contractFormSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const startFresh =
|
||||
(location.state as { fresh?: boolean } | null)?.fresh === true;
|
||||
useContractDraft({ form, step, setStep, fresh: startFresh });
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const operationType = form.watch("operationType");
|
||||
|
||||
const visibleSteps = useMemo(() => CONTRACT_STEPS, []);
|
||||
const visibleStepIds = useMemo<number[]>(
|
||||
() => visibleSteps.map((s) => s.id),
|
||||
[visibleSteps],
|
||||
);
|
||||
const currentStepIndex = visibleStepIds.indexOf(step);
|
||||
const isLastStep = currentStepIndex === visibleStepIds.length - 1;
|
||||
const isFirstStep = currentStepIndex <= 0;
|
||||
const goToStep = (delta: number) => {
|
||||
const idx = visibleStepIds.indexOf(step);
|
||||
const nextIdx = Math.min(
|
||||
visibleStepIds.length - 1,
|
||||
Math.max(0, idx + delta),
|
||||
);
|
||||
setStep(visibleStepIds[nextIdx]);
|
||||
};
|
||||
|
||||
const direction = useMemo(() => {
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const destination = referenceData?.yard.find(
|
||||
(y) => y.id === destinationYard,
|
||||
);
|
||||
return (
|
||||
getRouteDirection(origin, destination) ??
|
||||
(operationType ? operationToTradeDirection(operationType) : null)
|
||||
);
|
||||
}, [originYard, destinationYard, operationType, referenceData]);
|
||||
|
||||
const profileTypes = useMemo(
|
||||
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
|
||||
[auth.company],
|
||||
);
|
||||
|
||||
const allowedOperations = useMemo<OperationType[]>(
|
||||
() => allowedOperationsForProfiles(profileTypes),
|
||||
[profileTypes],
|
||||
);
|
||||
|
||||
const handleOperationSelect = (op: OperationType) => {
|
||||
if (op === "intercity") return;
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
};
|
||||
|
||||
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 valid = await form.trigger(contractStepFields[step], {
|
||||
shouldFocus: true,
|
||||
});
|
||||
if (!valid) return;
|
||||
goToStep(1);
|
||||
}
|
||||
|
||||
function buildApiPayload(data: ContractFormValues): CreateContractPayload {
|
||||
if (data.contractType === "renewal" && !data.previousContractRef) {
|
||||
form.setError("previousContractRef", {
|
||||
type: "manual",
|
||||
message: "Select a previous contract reference.",
|
||||
});
|
||||
setStep(1);
|
||||
throw new Error("Validation failed");
|
||||
}
|
||||
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
const isContainer = data.cargoType === "container";
|
||||
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||
// size (+ optional commodity); bulk: a single commodity row.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
cargoTypeId: data.cargoCommodityId || undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
||||
// extras only apply to GENERAL contracts.
|
||||
const isGeneral = data.contractKind === "general_contract";
|
||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||
{
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
sortOrder: 0,
|
||||
},
|
||||
...(isGeneral
|
||||
? (data.extraRoutes ?? [])
|
||||
.filter((r) => r.originYard && r.destinationYard)
|
||||
.map((r, i) => ({
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
sortOrder: i + 1,
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
contractKind: isGeneral
|
||||
? Freight.ContractKind.General
|
||||
: Freight.ContractKind.OneTime,
|
||||
tradeDirection: direction!,
|
||||
freightType: isContainer
|
||||
? Freight.ContractFreightType.Container
|
||||
: Freight.ContractFreightType.Bulk,
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
paymentCurrency: data.paymentCurrency,
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
isHazardous: data.isHazardous,
|
||||
// Reefer is a contract-level flag for both container and bulk.
|
||||
isReefer: data.isRefrigerated,
|
||||
...(data.estimatedShipmentDate
|
||||
? {
|
||||
estimatedShipmentDate: new Date(
|
||||
data.estimatedShipmentDate,
|
||||
).toISOString(),
|
||||
}
|
||||
: {}),
|
||||
...(data.previousContractRef
|
||||
? { renewalOfId: data.previousContractRef }
|
||||
: {}),
|
||||
...(serviceType?.includesFirstMile && data.firstMile.enabled
|
||||
? {
|
||||
firstMilePickupAddress: data.firstMile.pickUpAddress,
|
||||
firstMilePickupLat: data.firstMile.lat ?? undefined,
|
||||
firstMilePickupLng: data.firstMile.lng ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(serviceType?.includesLastMile && data.lastMile.enabled
|
||||
? {
|
||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
|
||||
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
|
||||
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(serviceType?.includesCustoms && data.customsClearingEnabled
|
||||
? {
|
||||
customsClearingEnabled: true,
|
||||
customsClearingAgent: data.customsClearingAgent || undefined,
|
||||
}
|
||||
: { customsClearingEnabled: false }),
|
||||
cargoScope,
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
const handleSaveDraft = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
persistAndPriceMutation.mutate({
|
||||
payload: apiPayload,
|
||||
mode: "draft",
|
||||
existingContractId: priceContractId,
|
||||
});
|
||||
} catch {
|
||||
// validation error already surfaced
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitContract = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
persistAndPriceMutation.mutate({
|
||||
payload: apiPayload,
|
||||
mode: "submit",
|
||||
existingContractId: priceContractId,
|
||||
});
|
||||
} catch {
|
||||
// validation error already surfaced
|
||||
}
|
||||
});
|
||||
|
||||
const isPricing =
|
||||
persistAndPriceMutation.isPending || confirmMutation.isPending;
|
||||
|
||||
function closePriceModal() {
|
||||
setPriceModalMode(null);
|
||||
if (priceModalMode === "draft" && priceContractId) {
|
||||
navigate(`/contracts/${priceContractId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDraftModalOk() {
|
||||
setPriceModalMode(null);
|
||||
if (priceContractId) navigate(`/contracts/${priceContractId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: "28px 0 0",
|
||||
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="24px"
|
||||
align="flex-end"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
New Contract
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Define your freight contract — scope, routes, and unit rates. Book
|
||||
shipments against it after signing.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => navigate("/contracts")}
|
||||
>
|
||||
Back to Contracts
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
id="new-contract-form"
|
||||
className="flex flex-col"
|
||||
style={{ flex: 1 }}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
<StepIndicator step={step} steps={visibleSteps} />
|
||||
</Box>
|
||||
|
||||
{persistAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
radius="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to save contract or generate price
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{persistAndPriceMutation.error instanceof Error
|
||||
? persistAndPriceMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<Step0OperationType
|
||||
form={form}
|
||||
allowedOperations={allowedOperations}
|
||||
onSelect={handleOperationSelect}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Step1ContractType form={form} referenceData={referenceData} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Step2ServiceType referenceData={referenceData} form={form} />
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Step3CargoScope
|
||||
form={form}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Step4Route
|
||||
form={form}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && <StepDocuments form={form} />}
|
||||
{step === 7 && (
|
||||
<Step8Review
|
||||
form={form}
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
onboardingDocs={onboardingDocs}
|
||||
pricing={
|
||||
pricingData
|
||||
? {
|
||||
currency: pricingData.currency,
|
||||
lineItems: pricingData.lineItems,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmitContract}
|
||||
saveDraftPending={
|
||||
persistAndPriceMutation.isPending &&
|
||||
persistAndPriceMutation.variables?.mode === "draft"
|
||||
}
|
||||
submitPending={
|
||||
persistAndPriceMutation.isPending &&
|
||||
persistAndPriceMutation.variables?.mode === "submit"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
zIndex: 20,
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
backgroundColor: "rgba(255,255,255,0.94)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
padding: "16px 24px",
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" className="mx-auto max-w-4xl">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => goToStep(-1)}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{!isLastStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
rightSection={<ChevronRight size={16} />}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={handleSubmitContract}
|
||||
loading={isPricing}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
{/* Unit-rate quotation modal (doc §9.3 — Approve Quotation). */}
|
||||
<Modal
|
||||
opened={priceModalMode !== null && pricingData !== null}
|
||||
onClose={closePriceModal}
|
||||
title={
|
||||
<Text fw={700}>
|
||||
{priceModalMode === "submit"
|
||||
? "Approve your quotation"
|
||||
: "Draft saved — unit-rate quotation"}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{pricingData && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceModalMode === "submit"
|
||||
? "Review your unit rates below. Approve to submit the contract for EDR staff review, edit & regenerate to change details, or discard this draft."
|
||||
: "Your contract has been saved as a draft. Here are your estimated unit rates."}
|
||||
</Text>
|
||||
<Box
|
||||
p="lg"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: "1px solid var(--mantine-color-edr-border-0)",
|
||||
background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
mb="xs"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Final amount is calculated at booking — quantities are unknown at
|
||||
the contract stage.
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group
|
||||
key={item.code}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" c="#10202F" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.containerSize && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.containerSize}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{item.unitPrice.toLocaleString()} {pricingData.currency}{" "}
|
||||
<Text span fz={12} fw={600} c="edr-muted">
|
||||
/ {formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{pricingData.lineItems.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No unit rates available.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
{pricingData.warnings && pricingData.warnings.length > 0 && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="orange.7"
|
||||
p="xs"
|
||||
className="rounded bg-orange-50"
|
||||
>
|
||||
{pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm" mt="md">
|
||||
{priceModalMode === "submit" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
loading={rejectMutation.isPending}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => setPriceModalMode(null)}
|
||||
disabled={
|
||||
rejectMutation.isPending || confirmMutation.isPending
|
||||
}
|
||||
>
|
||||
Edit & regenerate
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
loading={confirmMutation.isPending}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
Approve & submit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={handleDraftModalOk}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Re-priced on resubmit — confirm the new unit rates. */}
|
||||
<Modal
|
||||
opened={priceChangeResult !== null}
|
||||
onClose={() => setPriceChangeResult(null)}
|
||||
title={<Text fw={700}>Unit rates changed</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
{priceChangeResult && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceChangeResult.message ??
|
||||
"The contract unit rates have been updated. Confirm to submit with the new schedule."}
|
||||
</Text>
|
||||
{priceChangeResult.lineItems &&
|
||||
priceChangeResult.lineItems.length > 0 && (
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: "1px solid var(--mantine-color-edr-border-0)",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{priceChangeResult.lineItems.map((item) => (
|
||||
<Group
|
||||
key={item.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
>
|
||||
<Text size="sm" c="#10202F" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="#10202F">
|
||||
{item.unitPrice.toLocaleString()}{" "}
|
||||
{priceChangeResult.currency} /{" "}
|
||||
{formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setPriceChangeResult(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={confirmSubmitMutation.isPending}
|
||||
onClick={() => confirmSubmitMutation.mutate()}
|
||||
>
|
||||
Confirm & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function GateNotice({
|
||||
title,
|
||||
body,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: "28px",
|
||||
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<AlertCircle size={20} />}
|
||||
radius="md"
|
||||
style={{ maxWidth: "500px" }}
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" mb="md">
|
||||
{body}
|
||||
</Text>
|
||||
<Button color="orange" onClick={onAction} mt="md">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Package,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import { OperationDatePicker } from "@/pages/bookings/clearance/OperationDatePicker";
|
||||
import {
|
||||
SelectField,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
fieldStyles,
|
||||
} from "./new-contract-form/shared";
|
||||
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import {
|
||||
SHIPMENT_STEPS,
|
||||
ShipmentFormInputValues,
|
||||
ShipmentFormValues,
|
||||
initialShipmentFormValues,
|
||||
shipmentFormSchema,
|
||||
shipmentStepFields,
|
||||
} from "./new-shipment-form/schema";
|
||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||
|
||||
type ShipmentForm = ReturnType<
|
||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||
>;
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const { data: contract, isLoading } = useQuery(
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
||||
defaultValues: initialShipmentFormValues,
|
||||
resolver: zodResolver(shipmentFormSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.createBookingUnderContract.call({ id: id!, dto }),
|
||||
onSuccess: (booking) => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||
});
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const visibleStepIds = useMemo(() => SHIPMENT_STEPS.map((s) => s.id), []);
|
||||
const currentStepIndex = visibleStepIds.indexOf(step);
|
||||
const isLastStep = currentStepIndex === visibleStepIds.length - 1;
|
||||
const isFirstStep = currentStepIndex <= 0;
|
||||
const goToStep = (delta: number) => {
|
||||
const idx = visibleStepIds.indexOf(step);
|
||||
const nextIdx = Math.min(
|
||||
visibleStepIds.length - 1,
|
||||
Math.max(0, idx + delta),
|
||||
);
|
||||
setStep(visibleStepIds[nextIdx]);
|
||||
};
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(shipmentStepFields[step], {
|
||||
shouldFocus: true,
|
||||
});
|
||||
if (valid) goToStep(1);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih={400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contract) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="xs">
|
||||
Contract not found
|
||||
</Text>
|
||||
<Button variant="default" onClick={() => navigate("/contracts")}>
|
||||
Back to contracts
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Path A guard — customs contracts are GL-booked, not customer-booked.
|
||||
if (contract.customsClearingEnabled) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
|
||||
<Text fw={700} mb="xs">
|
||||
Bookings for this contract are handled by Global Logistics
|
||||
</Text>
|
||||
<Text size="sm" mb="md">
|
||||
This contract includes customs clearance. Upload your clearance
|
||||
documents and Global Logistics will create the booking for you.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
|
||||
>
|
||||
Go to clearance
|
||||
</Button>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDto(values: ShipmentFormValues): Freight.CreateBookingUnderContractDto {
|
||||
const isContainer = contract!.freightType === "CONTAINER";
|
||||
return {
|
||||
...(values.contractRouteId
|
||||
? { contractRouteId: values.contractRouteId }
|
||||
: {}),
|
||||
scheduledDate: new Date(values.scheduledDate).toISOString(),
|
||||
...(isContainer
|
||||
? {
|
||||
containers: values.containers
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: Number(l.quantity),
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber || undefined,
|
||||
vgmTons: Number(u.vgmTons),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
: {
|
||||
bulkLines: [
|
||||
{
|
||||
cargoTypeId: contract!.cargoScope?.[0]?.cargoTypeId ?? null,
|
||||
cargoWeightTons: values.cargoWeightTons
|
||||
? Number(values.cargoWeightTons)
|
||||
: undefined,
|
||||
itemCount: values.itemCount
|
||||
? Number(values.itemCount)
|
||||
: undefined,
|
||||
hazardousQuantity:
|
||||
Number(values.bulkHazardousQuantity || 0) || undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
...(values.notes ? { notes: values.notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
submitMutation.mutate(buildDto(values));
|
||||
});
|
||||
|
||||
const routes = contract.routes ?? [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: "28px 0 0",
|
||||
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" px="24px" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
New Shipment Booking
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Book a shipment against contract {contract.reference}.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => navigate(`/contracts/${contract.id}`)}
|
||||
>
|
||||
Back to contract
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<form
|
||||
className="flex flex-col"
|
||||
style={{ flex: 1 }}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
<StepIndicator step={step} steps={SHIPMENT_STEPS} />
|
||||
</Box>
|
||||
|
||||
{submitMutation.isError && (
|
||||
<Alert color="red" icon={<AlertCircle size={16} />} radius="md" mb="lg">
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to create the shipment booking
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{submitMutation.error instanceof Error
|
||||
? submitMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<RouteStep form={form} contract={contract} routes={routes} />
|
||||
)}
|
||||
{step === 1 && (
|
||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||
)}
|
||||
{step === 2 && <CargoStep form={form} contract={contract} />}
|
||||
{step === 3 && <ReviewStep form={form} contract={contract} />}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
zIndex: 20,
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
backgroundColor: "rgba(255,255,255,0.94)",
|
||||
backdropFilter: "blur(14px)",
|
||||
padding: "16px 24px",
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" className="mx-auto max-w-4xl">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => goToStep(-1)}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{!isLastStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
rightSection={<ChevronRight size={16} />}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
Submit booking
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteStep({
|
||||
form,
|
||||
contract,
|
||||
routes,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
contract: Freight.IContract;
|
||||
routes: Freight.IContractRoute[];
|
||||
}) {
|
||||
const multiRoute = routes.length > 1;
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<MapPin size={22} />}
|
||||
title="Route"
|
||||
description={
|
||||
multiRoute
|
||||
? "Choose which contracted route this shipment ships on."
|
||||
: "This shipment ships on the contract's only route."
|
||||
}
|
||||
/>
|
||||
{multiRoute ? (
|
||||
<Controller
|
||||
name="contractRouteId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Contract route *"
|
||||
placeholder="Select a route..."
|
||||
data={routes.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.originYard?.label ?? r.originYardId} → ${
|
||||
r.destinationYard?.label ?? r.destinationYardId
|
||||
}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"} →{" "}
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleStep({
|
||||
form,
|
||||
routes,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
contract: Freight.IContract;
|
||||
routes: Freight.IContractRoute[];
|
||||
}) {
|
||||
const contractRouteId = form.watch("contractRouteId");
|
||||
const route =
|
||||
routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<CalendarDays size={22} />}
|
||||
title="Schedule"
|
||||
description="Pick the binding shipment day. Only days with an open departure on your route can be selected."
|
||||
/>
|
||||
<Controller
|
||||
name="scheduledDate"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Box>
|
||||
<StepLabel>Shipment day *</StepLabel>
|
||||
<Box mt={10}>
|
||||
<OperationDatePicker
|
||||
originYardId={route?.originYardId}
|
||||
destinationYardId={route?.destinationYardId}
|
||||
value={field.value ?? ""}
|
||||
onChange={(d) => field.onChange(d)}
|
||||
/>
|
||||
</Box>
|
||||
{fieldState.error?.message && (
|
||||
<Text fz="xs" c="red" mt={6}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CargoStep({
|
||||
form,
|
||||
contract,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
contract: Freight.IContract;
|
||||
}) {
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
// Sizes enabled by the contract scope.
|
||||
const sizes = useMemo(
|
||||
() =>
|
||||
(contract.cargoScope ?? [])
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft"),
|
||||
[contract],
|
||||
);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once (never during
|
||||
// render — appending in render loops). Subsequent renders reuse the lines.
|
||||
const seededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededRef.current || !isContainer) return;
|
||||
seededRef.current = true;
|
||||
const existing = form.getValues("containers") ?? [];
|
||||
if (existing.length > 0) return;
|
||||
form.setValue(
|
||||
"containers",
|
||||
sizes.map((size) => ({
|
||||
containerSize: size,
|
||||
quantity: "1",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
})),
|
||||
{ shouldValidate: false },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lines = form.watch("containers") ?? [];
|
||||
|
||||
if (isContainer) {
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Enter the quantity and per-container details for each size in your contract scope."
|
||||
/>
|
||||
<Stack gap={18}>
|
||||
{lines.map((line, index) => (
|
||||
<ContainerLineEditor
|
||||
key={line.containerSize}
|
||||
form={form}
|
||||
index={index}
|
||||
size={line.containerSize}
|
||||
isHazardous={contract.isHazardous}
|
||||
isReefer={contract.isReefer}
|
||||
/>
|
||||
))}
|
||||
{sizes.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Enter the amount you are shipping for this booking."
|
||||
/>
|
||||
<Stack gap={14}>
|
||||
<Controller
|
||||
name="cargoWeightTons"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Quantity (tons)"
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
step={0.01}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="itemCount"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Item count (if applicable)"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{contract.isHazardous && (
|
||||
<Controller
|
||||
name="bulkHazardousQuantity"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Hazardous quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{bulkUnitIsItem && null}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ContainerLineEditor({
|
||||
form,
|
||||
index,
|
||||
size,
|
||||
isHazardous,
|
||||
isReefer,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
index: number;
|
||||
size: "20ft" | "40ft";
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
}) {
|
||||
const line = form.watch(`containers.${index}`);
|
||||
const quantity = Number(line?.quantity || 0);
|
||||
const units = line?.units ?? [];
|
||||
|
||||
// Keep the units array length in sync with the entered quantity.
|
||||
const syncUnits = (qty: number) => {
|
||||
const current = form.getValues(`containers.${index}.units`) ?? [];
|
||||
const next = [...current];
|
||||
while (next.length < qty)
|
||||
next.push({ containerNumber: "", sealNumber: "", vgmTons: "" });
|
||||
next.length = Math.max(0, qty);
|
||||
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 16 }}
|
||||
>
|
||||
<Text fz={14} fw={700} c="#10202F" mb={10}>
|
||||
{size} containers
|
||||
</Text>
|
||||
<Group gap={12} grow mb={12} align="flex-start">
|
||||
<Controller
|
||||
name={`containers.${index}.quantity`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.currentTarget.value);
|
||||
syncUnits(Number(e.currentTarget.value || 0));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isHazardous && (
|
||||
<Controller
|
||||
name={`containers.${index}.hazardousQuantity`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Controller
|
||||
name={`containers.${index}.reeferQuantity`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
<Stack gap={10} mt={8}>
|
||||
{Array.from({ length: Math.max(quantity, units.length) }).map(
|
||||
(_, u) => (
|
||||
<Group key={u} gap={10} grow align="flex-start">
|
||||
<Controller
|
||||
name={`containers.${index}.units.${u}.containerNumber`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
label={u === 0 ? "Container number *" : undefined}
|
||||
placeholder="e.g. MSCU1234567"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={`containers.${index}.units.${u}.sealNumber`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
label={u === 0 ? "Seal number" : undefined}
|
||||
placeholder="Optional"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={`containers.${index}.units.${u}.vgmTons`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
label={u === 0 ? "VGM (tons) *" : undefined}
|
||||
placeholder="e.g. 24.5"
|
||||
min={0}
|
||||
step={0.01}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewStep({
|
||||
form,
|
||||
contract,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
contract: Freight.IContract;
|
||||
}) {
|
||||
const values = form.watch() as ShipmentFormValues;
|
||||
const total = useMemo(
|
||||
() => computeShipmentTotal(contract, values),
|
||||
[contract, values],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Review & Submit"
|
||||
description="Review your shipment and the computed total before submitting."
|
||||
/>
|
||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={800} c="#10202F" mb="sm">
|
||||
Estimated total
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{total.lines.map((line, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" c="#10202F" fw={500}>
|
||||
{line.label}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{line.quantity.toLocaleString()} ×{" "}
|
||||
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
|
||||
{formatRateUnit(line.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz="sm" fw={600} c="#10202F" style={{ whiteSpace: "nowrap" }}>
|
||||
{line.amount.toLocaleString()} {total.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{total.lines.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
Enter cargo details to see the computed total.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
mt="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
|
||||
border: "1px solid #E6ECF2",
|
||||
}}
|
||||
>
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="edr-green" style={{ letterSpacing: "0.06em" }}>
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28} c="#10202F" mt={4}>
|
||||
{total.total.toLocaleString()}{" "}
|
||||
<Text span fz={16} fw={700} c="edr-muted">
|
||||
{total.currency}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for this shipment…"
|
||||
rows={3}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { formatQuantity, GREEN, INK } from "./contract-ui";
|
||||
|
||||
interface PlaceOrderDialogProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
contract: Freight.IBooking;
|
||||
pool: Freight.ContractQuantityLine[];
|
||||
onPlaced: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a drawdown order against an ACTIVE general contract. The customer picks
|
||||
* a shipment day (constrained to days with a departure on the contract's route)
|
||||
* and a quantity per pool line, validated against the remaining quantity.
|
||||
*/
|
||||
export function PlaceOrderDialog({
|
||||
opened,
|
||||
onClose,
|
||||
contract,
|
||||
pool,
|
||||
onPlaced,
|
||||
}: PlaceOrderDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
|
||||
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
|
||||
const [routeLineId, setRouteLineId] = useState<string | null>(null);
|
||||
// Per-order hazardous / reefer counts, entered once when the toggle is on.
|
||||
const [hazardousOn, setHazardousOn] = useState(false);
|
||||
const [hazardousQty, setHazardousQty] = useState<number | "">("");
|
||||
const [reeferOn, setReeferOn] = useState(false);
|
||||
const [reeferQty, setReeferQty] = useState<number | "">("");
|
||||
|
||||
// Multi-route contracts expose route lines; single-route contracts return [].
|
||||
const { data: routeLines = [] } = useQuery({
|
||||
...api.bookingOrders.routes.queryOptions({
|
||||
input: { contractBookingId: contract.id },
|
||||
}),
|
||||
enabled: opened,
|
||||
});
|
||||
const isMultiRoute = routeLines.length > 0;
|
||||
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
|
||||
|
||||
// The route the order ships on drives ONLY the available-days (schedule) query:
|
||||
// the chosen lane for multi-route contracts, else the contract's own
|
||||
// origin/destination. Quantity is always drawn from the shared pool below —
|
||||
// routes are pure lanes and carry no quantity.
|
||||
const originYardId = isMultiRoute
|
||||
? selectedRoute?.originYardId
|
||||
: contract.originYard?.id;
|
||||
const destinationYardId = isMultiRoute
|
||||
? selectedRoute?.destinationYardId
|
||||
: contract.destinationYard?.id;
|
||||
|
||||
const { data: availableDays, isLoading: daysLoading } = useQuery({
|
||||
...api.bookings.getAvailableDays.queryOptions({
|
||||
input: { originYardId, destinationYardId },
|
||||
}),
|
||||
enabled: opened && !!originYardId && !!destinationYardId,
|
||||
});
|
||||
|
||||
const dayOptions = useMemo(
|
||||
() =>
|
||||
(availableDays ?? []).map((d) => ({
|
||||
value: d,
|
||||
label: new Date(d).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
})),
|
||||
[availableDays],
|
||||
);
|
||||
|
||||
const lineKey = (line: Freight.ContractQuantityLine) =>
|
||||
line.containerTypeId ?? "__bulk__";
|
||||
|
||||
const createMutation = useMutation({
|
||||
...api.bookingOrders.create.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookingOrders.listByContract.queryKey({
|
||||
contractBookingId: contract.id,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookingOrders.pool.queryKey({
|
||||
contractBookingId: contract.id,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookingOrders.routes.queryKey({
|
||||
contractBookingId: contract.id,
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: contract.id }),
|
||||
});
|
||||
reset();
|
||||
onPlaced();
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setScheduledDate(null);
|
||||
setQuantities({});
|
||||
setRouteLineId(null);
|
||||
setHazardousOn(false);
|
||||
setHazardousQty("");
|
||||
setReeferOn(false);
|
||||
setReeferQty("");
|
||||
}
|
||||
|
||||
// Total quantity across the order; haz/reefer counts cannot exceed it. Always
|
||||
// summed from the shared pool lines, regardless of routing.
|
||||
const orderTotalQty = pool.reduce((sum, l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return sum + (typeof raw === "number" ? raw : 0);
|
||||
}, 0);
|
||||
|
||||
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
|
||||
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
|
||||
const hazReeferValid =
|
||||
(!hazardousOn || (hazValue > 0 && hazValue <= orderTotalQty)) &&
|
||||
(!reeferOn || (reeferValue > 0 && reeferValue <= orderTotalQty));
|
||||
|
||||
function handleClose() {
|
||||
if (createMutation.isPending) return;
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!scheduledDate) return;
|
||||
// Multi-route contracts require a chosen lane (drives scheduling/routing).
|
||||
if (isMultiRoute && !selectedRoute) return;
|
||||
|
||||
const lines: Freight.CreateBookingOrderLineDto[] = pool
|
||||
.map((line) => {
|
||||
const raw = quantities[lineKey(line)];
|
||||
const qty = typeof raw === "number" ? raw : 0;
|
||||
return {
|
||||
containerTypeId: isContainer ? line.containerTypeId : null,
|
||||
quantity: qty,
|
||||
};
|
||||
})
|
||||
.filter((l) => l.quantity > 0);
|
||||
|
||||
if (lines.length === 0) return;
|
||||
if (!hazReeferValid) return;
|
||||
|
||||
// Haz/reefer are entered once per order; attach the counts to the first line.
|
||||
lines[0] = {
|
||||
...lines[0],
|
||||
hazardousQuantity: hazValue,
|
||||
reeferQuantity: reeferValue,
|
||||
};
|
||||
|
||||
createMutation.mutate({
|
||||
contractBookingId: contract.id,
|
||||
// The lane only routes/schedules the order; quantity comes from the pool.
|
||||
...(isMultiRoute && selectedRoute
|
||||
? { routeLineId: selectedRoute.routeLineId }
|
||||
: {}),
|
||||
scheduledDate: new Date(scheduledDate).toISOString(),
|
||||
lines,
|
||||
});
|
||||
}
|
||||
|
||||
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
|
||||
const hasQuantity = pool.some((l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return typeof raw === "number" && raw > 0;
|
||||
});
|
||||
const canSubmit =
|
||||
!!scheduledDate &&
|
||||
hasQuantity &&
|
||||
hazReeferValid &&
|
||||
(!isMultiRoute || !!selectedRoute) &&
|
||||
!createMutation.isPending;
|
||||
|
||||
// Routes are pure lanes — the label shows origin → destination only.
|
||||
const routeOptions = routeLines.map((r) => ({
|
||||
value: r.routeLineId,
|
||||
label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<PackagePlus size={18} color={GREEN} />
|
||||
<Text fw={700} style={{ color: INK }}>
|
||||
Place an order
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Draw down from contract <strong>{contract.reference}</strong>. Cargo
|
||||
and service are inherited — pick {isMultiRoute ? "a route, " : ""}a
|
||||
shipment date and quantity.
|
||||
</Text>
|
||||
|
||||
{isMultiRoute && (
|
||||
<Select
|
||||
label="Route"
|
||||
placeholder="Select a contracted route"
|
||||
data={routeOptions}
|
||||
value={routeLineId}
|
||||
onChange={(v) => {
|
||||
setRouteLineId(v);
|
||||
setScheduledDate(null);
|
||||
setQuantities({});
|
||||
}}
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
styles={{ input: { height: 44 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Shipment date"
|
||||
placeholder={daysLoading ? "Loading available days…" : "Select a day"}
|
||||
data={dayOptions}
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
disabled={daysLoading || (isMultiRoute && !selectedRoute)}
|
||||
radius="md"
|
||||
leftSection={<CalendarDays size={16} />}
|
||||
nothingFoundMessage="No departures on this route"
|
||||
searchable
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
styles={{ input: { height: 44 } }}
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Quantity
|
||||
</Text>
|
||||
{isMultiRoute && !selectedRoute ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
Select a route first, then enter how much to ship on it.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{orderableLines.length === 0 && (
|
||||
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
|
||||
This contract is fully drawn down — no quantity remains.
|
||||
</Alert>
|
||||
)}
|
||||
{orderableLines.map((line) => {
|
||||
const key = lineKey(line);
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
return (
|
||||
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}{" "}
|
||||
remaining
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={quantities[key] ?? ""}
|
||||
onChange={(v) =>
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[key]: v === "" ? "" : Number(v),
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={line.remainingQuantity}
|
||||
step={
|
||||
isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5
|
||||
}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Cargo handling
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Switch
|
||||
label="Hazardous cargo"
|
||||
checked={hazardousOn}
|
||||
onChange={(e) => {
|
||||
setHazardousOn(e.currentTarget.checked);
|
||||
if (!e.currentTarget.checked) setHazardousQty("");
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
{hazardousOn && (
|
||||
<NumberInput
|
||||
value={hazardousQty}
|
||||
onChange={(v) => setHazardousQty(v === "" ? "" : Number(v))}
|
||||
min={0}
|
||||
max={orderTotalQty || undefined}
|
||||
step={isContainer ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="How many"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Switch
|
||||
label="Refrigerated (reefer)"
|
||||
checked={reeferOn}
|
||||
onChange={(e) => {
|
||||
setReeferOn(e.currentTarget.checked);
|
||||
if (!e.currentTarget.checked) setReeferQty("");
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
{reeferOn && (
|
||||
<NumberInput
|
||||
value={reeferQty}
|
||||
onChange={(v) => setReeferQty(v === "" ? "" : Number(v))}
|
||||
min={0}
|
||||
max={orderTotalQty || undefined}
|
||||
step={isContainer ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="How many"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{(hazardousOn || reeferOn) && (
|
||||
<Text fz={12} c="dimmed">
|
||||
Hazardous/reefer quantity cannot exceed the order total
|
||||
{orderTotalQty > 0 ? ` (${orderTotalQty})` : ""}. These add the
|
||||
relevant surcharge to this order's price.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{createMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: "Failed to place the order. Please try again."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={handleClose}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
Place order
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -51,38 +51,69 @@ export function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** Visual config for a general-contract status. */
|
||||
// Tone presets so every status maps to a consistent badge palette.
|
||||
const TONE = {
|
||||
neutral: { color: "#6B7C8E", bg: "#EEF2F6" },
|
||||
info: { color: "#2E5B96", bg: "#EAF1FB" },
|
||||
warning: { color: "#9A6700", bg: "#FFF6E5" },
|
||||
success: { color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
danger: { color: "#B42318", bg: "#FEECEB" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Visual config covering ALL contract statuses (`CONTRACT_STATUSES` in
|
||||
* `@edr/types`) plus the Path B clearance + renewal + booking statuses reused by
|
||||
* badges on the contract detail page.
|
||||
*/
|
||||
export const CONTRACT_STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
DRAFT: { label: "Draft", color: "#6B7C8E", bg: "#EEF2F6" },
|
||||
SUBMITTED: { label: "Submitted", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
PENDING_APPROVAL: { label: "Pending Approval", color: "#9A6700", bg: "#FFF6E5" },
|
||||
APPROVED_PENDING_SIGNATURE: { label: "Awaiting Signature", color: "#9A6700", bg: "#FFF6E5" },
|
||||
CONTRACT_READY: { label: "Ready to Sign", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
SIGNED_CUSTOMER: { label: "Signed", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
||||
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
|
||||
// Drawdown-order statuses, mirrored from the order's child booking as it moves
|
||||
// through the same flow as a one-time booking (clearance → accept → pay →
|
||||
// allocate). Reused by the order badge on ContractDetailPage.
|
||||
PENDING: { label: "Pending", color: "#9A6700", bg: "#FFF6E5" },
|
||||
AWAITING_DOCUMENTS: { label: "Awaiting Documents", color: "#9A6700", bg: "#FFF6E5" },
|
||||
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
CLEARANCE_READY: { label: "Clearance Ready", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
OPERATION_REQUEST_PENDING: { label: "Operation Review", color: "#9A6700", bg: "#FFF6E5" },
|
||||
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", color: "#9A6700", bg: "#FFF6E5" },
|
||||
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", color: "#9A6700", bg: "#FFF6E5" },
|
||||
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", color: "#9A6700", bg: "#FFF6E5" },
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
||||
PAID: { label: "Paid", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
IN_TRANSIT: { label: "In Transit", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
COMPLETED: { label: "Completed", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
|
||||
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
|
||||
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
|
||||
// ── Contract lifecycle ──
|
||||
DRAFT: { label: "Draft", ...TONE.neutral },
|
||||
SUBMITTED: { label: "Submitted", ...TONE.info },
|
||||
PRICE_CHANGED_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning },
|
||||
CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning },
|
||||
PENDING_APPROVAL: { label: "Pending Approval", ...TONE.warning },
|
||||
APPROVED: { label: "Approved", ...TONE.info },
|
||||
APPROVED_PENDING_SIGNATURE: { label: "Awaiting Signature", ...TONE.warning },
|
||||
CONTRACT_READY: { label: "Ready to Sign", ...TONE.info },
|
||||
SIGNED_CUSTOMER: { label: "Signed — Awaiting Staff", ...TONE.info },
|
||||
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
||||
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
||||
// ── Path B pre-booking clearance (contract-level) ──
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Upload Clearance Docs",
|
||||
...TONE.warning,
|
||||
},
|
||||
CLEARANCE_UNDER_REVIEW: { label: "Clearance Under Review", ...TONE.info },
|
||||
CLEARANCE_READY_FOR_BOOKING: { label: "Cleared — Awaiting Booking", ...TONE.success },
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: { label: "Shipment In Progress", ...TONE.info },
|
||||
// ── Terminal ──
|
||||
CONTRACT_CLOSED: { label: "Closed", ...TONE.neutral },
|
||||
EXPIRED: { label: "Expired", ...TONE.danger },
|
||||
REJECTED: { label: "Rejected", ...TONE.danger },
|
||||
CANCELLED: { label: "Cancelled", ...TONE.danger },
|
||||
// ── Renewal branch ──
|
||||
RENEWAL_DRAFT: { label: "Renewal Draft", ...TONE.neutral },
|
||||
RENEWAL_SUBMITTED: { label: "Renewal Submitted", ...TONE.info },
|
||||
RENEWAL_PENDING_APPROVAL: { label: "Renewal Pending Approval", ...TONE.warning },
|
||||
AMENDMENTS_PROPOSED: { label: "Amendments Proposed", ...TONE.warning },
|
||||
ARCHIVED: { label: "Archived", ...TONE.neutral },
|
||||
// ── Booking statuses reused by the contract's booking list badges ──
|
||||
PENDING: { label: "Pending", ...TONE.warning },
|
||||
AWAITING_DOCUMENTS: { label: "Awaiting Documents", ...TONE.warning },
|
||||
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", ...TONE.info },
|
||||
CLEARANCE_READY: { label: "Clearance Ready", ...TONE.success },
|
||||
OPERATION_REQUEST_PENDING: { label: "Operation Review", ...TONE.warning },
|
||||
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning },
|
||||
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning },
|
||||
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", ...TONE.warning },
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting Payment", ...TONE.warning },
|
||||
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
|
||||
PAID: { label: "Paid", ...TONE.success },
|
||||
IN_TRANSIT: { label: "In Transit", ...TONE.info },
|
||||
COMPLETED: { label: "Completed", ...TONE.success },
|
||||
};
|
||||
|
||||
export function ContractStatusBadge({ status }: { status: string }) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { CONTRACT_STEPS } from "./schema";
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-5)";
|
||||
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
|
||||
const BORDER = "var(--mantine-color-edr-border-0)";
|
||||
const MUTED = "var(--mantine-color-edr-muted-0)";
|
||||
const INK = "var(--mantine-color-edr-text-0)";
|
||||
|
||||
type StepItem = (typeof CONTRACT_STEPS)[number];
|
||||
|
||||
export function StepIndicator({
|
||||
step,
|
||||
steps = CONTRACT_STEPS as readonly StepItem[],
|
||||
}: {
|
||||
step: number;
|
||||
steps?: readonly StepItem[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
{steps.map((item, index) => {
|
||||
const done = step > item.id;
|
||||
const active = step === item.id;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div
|
||||
className="flex shrink-0 flex-col items-center gap-2"
|
||||
style={{ minWidth: 34 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
transition: "all 0.2s",
|
||||
...(done
|
||||
? {
|
||||
background:
|
||||
"linear-gradient(135deg, #12B981, #0A8A5F)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
|
||||
}
|
||||
: active
|
||||
? {
|
||||
border: `2.5px solid ${GREEN}`,
|
||||
color: GREEN_DEEP,
|
||||
backgroundColor: "#fff",
|
||||
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "#fff",
|
||||
color: MUTED,
|
||||
border: `2px solid ${BORDER}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{done ? (
|
||||
<Check style={{ width: 15, height: 15 }} strokeWidth={3} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: active ? 700 : 500,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: 72,
|
||||
display: "none",
|
||||
transition: "color 0.2s",
|
||||
color: step >= item.id ? INK : MUTED,
|
||||
}}
|
||||
className="md:!block"
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
margin: "16px 8px 0",
|
||||
transition: "background 0.3s",
|
||||
background: done
|
||||
? "linear-gradient(90deg, #0A8A5F, #12B981)"
|
||||
: BORDER,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The operation-gating + trade-direction helpers are identical for contracts
|
||||
// and bookings, so reuse the booking implementations rather than duplicating.
|
||||
export {
|
||||
allowedOperationsForProfiles,
|
||||
isForwarderOperation,
|
||||
operationToTradeDirection,
|
||||
operationToProfileType,
|
||||
getRouteDirection,
|
||||
} from "@/pages/bookings/new-booking-form/schema";
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { Banknote, Check, DollarSign } from "lucide-react";
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import {
|
||||
PAYMENT_CURRENCY_OPTIONS,
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
type PaymentCurrency,
|
||||
} from "./schema";
|
||||
import { OptionFieldError, StepLabel } from "./shared";
|
||||
|
||||
const CURRENCY_ICONS: Record<
|
||||
PaymentCurrency,
|
||||
{ icon: typeof DollarSign; color: string }
|
||||
> = {
|
||||
USD: { icon: DollarSign, color: "#4F46E5" },
|
||||
ETB: { icon: Banknote, color: "#0A6F4D" },
|
||||
};
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
control,
|
||||
}: {
|
||||
control: Control<ContractFormInputValues, any, ContractFormValues>;
|
||||
}) {
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||
Choose the currency for your freight quote and invoices.
|
||||
</Text>
|
||||
<Controller
|
||||
name="paymentCurrency"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
p={4}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "#F1F4F7",
|
||||
border: "1px solid #E6ECF2",
|
||||
}}
|
||||
>
|
||||
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
|
||||
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||
const selected = field.value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(option.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 9,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: selected ? "#fff" : "transparent",
|
||||
boxShadow: selected
|
||||
? "0 1px 3px rgba(16,32,47,0.10)"
|
||||
: "none",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: selected
|
||||
? CURRENCY_ICONS[option.value].color
|
||||
: "#94A3B8",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Box>
|
||||
<Text
|
||||
fz={14}
|
||||
fw={selected ? 700 : 600}
|
||||
c={selected ? "#10202F" : "#64748B"}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{selected && (
|
||||
<Check
|
||||
size={15}
|
||||
color={CURRENCY_ICONS[option.value].color}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
<Text fz={11.5} c="#6B7C8E" mt={8}>
|
||||
{
|
||||
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
|
||||
?.description
|
||||
}
|
||||
</Text>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { DeepPartial, Path } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
// Wizard steps for the contract creation flow. Mirrors the booking wizard but
|
||||
// the cargo step collects SCOPE only (no quantities) and the route step collects
|
||||
// a non-binding estimated shipment date (the binding date is at booking time).
|
||||
export const CONTRACT_STEPS = [
|
||||
{ id: 0, label: "Operation Type", short: "Operation" },
|
||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||
{ id: 3, label: "Cargo Scope", short: "Cargo" },
|
||||
{ id: 4, label: "Route", short: "Route" },
|
||||
{ id: 6, label: "Documents", short: "Documents" },
|
||||
{ id: 7, label: "Review & Submit", short: "Submit" },
|
||||
] as const;
|
||||
|
||||
export const OPERATION_TYPES = [
|
||||
"import",
|
||||
"export",
|
||||
"intercity",
|
||||
"import_ff",
|
||||
"export_ff",
|
||||
] as const;
|
||||
export type OperationType = (typeof OPERATION_TYPES)[number];
|
||||
|
||||
export type ContractDocuments = Record<string, File | File[] | null>;
|
||||
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
value: PaymentCurrency;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "USD",
|
||||
label: "USD",
|
||||
description: "US Dollar — international pricing and invoicing.",
|
||||
},
|
||||
{
|
||||
value: "ETB",
|
||||
label: "ETB",
|
||||
description: "Ethiopian Birr — local pricing and invoicing.",
|
||||
},
|
||||
];
|
||||
|
||||
// one_time → ContractKind.OneTime; general_contract → ContractKind.General.
|
||||
export const CONTRACT_KINDS = ["one_time", "general_contract"] as const;
|
||||
export type ContractKindOption = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
|
||||
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
|
||||
|
||||
export const contractFormSchema = z
|
||||
.object({
|
||||
// Operation drives trade direction (import/export → IMPORT/EXPORT;
|
||||
// intercity → DOMESTIC), gated by the company's onboarded profiles.
|
||||
operationType: z.enum(OPERATION_TYPES, "Select an operation type."),
|
||||
// One-time vs. general contract.
|
||||
contractKind: z.enum(CONTRACT_KINDS).default("one_time"),
|
||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||
previousContractRef: z.string().default(""),
|
||||
|
||||
serviceTypeId: z.string("Select a service type."),
|
||||
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||
|
||||
firstMile: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
pickUpAddress: z.string().default(""),
|
||||
lat: z.number().nullable().default(null),
|
||||
lng: z.number().nullable().default(null),
|
||||
})
|
||||
.refine((data) => !(data.enabled && !data.pickUpAddress.trim()), {
|
||||
message: "Select the pick-up location on the map.",
|
||||
path: ["pickUpAddress"],
|
||||
}),
|
||||
lastMile: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
deliveryAddress: z.string().default(""),
|
||||
lat: z.number().nullable().default(null),
|
||||
lng: z.number().nullable().default(null),
|
||||
})
|
||||
.refine((data) => !(data.enabled && !data.deliveryAddress.trim()), {
|
||||
message: "Select the delivery location on the map.",
|
||||
path: ["deliveryAddress"],
|
||||
}),
|
||||
equipmentReturn: z
|
||||
.enum(["with_return", "without_return"])
|
||||
.default("with_return"),
|
||||
customsClearingEnabled: z.boolean().default(false),
|
||||
customsClearingAgent: z.string().default(""),
|
||||
|
||||
// ── Cargo SCOPE (no quantities) ──
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
// Container scope: the enabled sizes (min 1). Each becomes a
|
||||
// contract_cargo_scope row.
|
||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||
// Optional commodity label for the contract PDF (container scope).
|
||||
cargoCommodityId: z.string().default(""),
|
||||
// Bulk scope: the cargo type path (group → commodity).
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
cargoFreeText: z.string().default(""),
|
||||
// Contract-level billing flags.
|
||||
isHazardous: z.boolean().default(false),
|
||||
isRefrigerated: z.boolean().default(false),
|
||||
|
||||
// ── Route ──
|
||||
originYard: z.string().min(1, "Select an origin yard."),
|
||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||
// Additional routes for a GENERAL contract (route #1 is the primary above).
|
||||
extraRoutes: z
|
||||
.array(
|
||||
z.object({
|
||||
originYard: z.string().default(""),
|
||||
destinationYard: z.string().default(""),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
// Non-binding estimate. The binding scheduled date is captured at booking.
|
||||
estimatedShipmentDate: z.string().default(""),
|
||||
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string().default(""),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!(data.contractType === "renewal" && !data.previousContractRef.trim()),
|
||||
{
|
||||
message: "Enter a previous contract reference.",
|
||||
path: ["previousContractRef"],
|
||||
},
|
||||
)
|
||||
.refine((data) => data.originYard !== "", {
|
||||
message: "Select an origin yard.",
|
||||
path: ["originYard"],
|
||||
})
|
||||
.refine((data) => data.destinationYard !== "", {
|
||||
message: "Select a destination yard.",
|
||||
path: ["destinationYard"],
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!(
|
||||
data.originYard &&
|
||||
data.destinationYard &&
|
||||
data.originYard === data.destinationYard
|
||||
),
|
||||
{
|
||||
message: "Destination must be different from origin.",
|
||||
path: ["destinationYard"],
|
||||
},
|
||||
)
|
||||
.superRefine((data, ctx) => {
|
||||
// Estimated shipment date is a planning value and is required for all
|
||||
// contracts (doc §7 step 4).
|
||||
if (!data.estimatedShipmentDate.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["estimatedShipmentDate"],
|
||||
message: "Select an estimated shipment date.",
|
||||
});
|
||||
}
|
||||
if (data.cargoType === "container") {
|
||||
// Container scope: at least one enabled size.
|
||||
if (data.enabledContainerSizes.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["enabledContainerSizes"],
|
||||
message: "Enable at least one container size.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.cargoType === "bulk") {
|
||||
// Bulk scope: a commodity is required.
|
||||
if (!data.cargoTypePath[0]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a cargo type.",
|
||||
});
|
||||
} else if (!data.cargoTypePath[1]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a commodity.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||
export type ContractFormInputValues = z.input<typeof contractFormSchema>;
|
||||
|
||||
export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
contractKind: "one_time",
|
||||
contractType: "new",
|
||||
previousContractRef: "",
|
||||
|
||||
serviceTypeId: "",
|
||||
paymentCurrency: "USD",
|
||||
firstMile: { enabled: false, pickUpAddress: "", lat: null, lng: null },
|
||||
lastMile: { enabled: false, deliveryAddress: "", lat: null, lng: null },
|
||||
equipmentReturn: "with_return",
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: "",
|
||||
|
||||
cargoType: "container",
|
||||
enabledContainerSizes: ["20ft"],
|
||||
cargoCommodityId: "",
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
isHazardous: false,
|
||||
isRefrigerated: false,
|
||||
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
extraRoutes: [],
|
||||
estimatedShipmentDate: "",
|
||||
|
||||
documents: {},
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const contractStepFields: Record<
|
||||
number,
|
||||
Array<Path<ContractFormValues>>
|
||||
> = {
|
||||
0: ["operationType"],
|
||||
1: ["contractKind", "contractType", "previousContractRef"],
|
||||
2: [
|
||||
"serviceTypeId",
|
||||
"paymentCurrency",
|
||||
"equipmentReturn",
|
||||
"customsClearingEnabled",
|
||||
"customsClearingAgent",
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
],
|
||||
3: [
|
||||
"cargoType",
|
||||
"enabledContainerSizes",
|
||||
"cargoCommodityId",
|
||||
"cargoTypePath",
|
||||
"cargoFreeText",
|
||||
"isHazardous",
|
||||
"isRefrigerated",
|
||||
],
|
||||
4: [
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"extraRoutes",
|
||||
"estimatedShipmentDate",
|
||||
],
|
||||
6: ["documents"],
|
||||
7: ["notes"],
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Combobox,
|
||||
Input,
|
||||
InputBase,
|
||||
Select,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { Loader } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldError as RhfFieldError,
|
||||
FieldValues,
|
||||
FieldPath,
|
||||
} from "react-hook-form";
|
||||
|
||||
// The contract wizard reuses the booking wizard's premium card chrome verbatim
|
||||
// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles).
|
||||
export {
|
||||
AlertBox,
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "@/pages/bookings/new-booking-form/shared";
|
||||
|
||||
import { fieldStyles } from "@/pages/bookings/new-booking-form/shared";
|
||||
|
||||
/**
|
||||
* Generic Select field — identical look to the booking wizard's SelectField but
|
||||
* generic over the form's value type so it works with the contract form.
|
||||
*/
|
||||
export function SelectField<
|
||||
TValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TValues> = FieldPath<TValues>,
|
||||
>({
|
||||
field,
|
||||
error,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
data,
|
||||
leftSection,
|
||||
}: {
|
||||
field: ControllerRenderProps<TValues, TName>;
|
||||
error?: RhfFieldError;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
data: string[] | { value: string; label: string }[];
|
||||
leftSection?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
data={data}
|
||||
value={
|
||||
field.value === undefined || field.value === null || field.value === ""
|
||||
? null
|
||||
: String(field.value)
|
||||
}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
error={error?.message}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AsyncComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AsyncComboboxField<
|
||||
TValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TValues> = FieldPath<TValues>,
|
||||
>({
|
||||
field,
|
||||
error,
|
||||
label,
|
||||
placeholder,
|
||||
options,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
field: ControllerRenderProps<TValues, TName>;
|
||||
error?: RhfFieldError;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
options: AsyncComboboxOption[];
|
||||
isLoading?: boolean;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const combobox = useCombobox();
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
return options.find((opt) => opt.value === field.value)?.label || "";
|
||||
}, [field.value, options]);
|
||||
|
||||
const handleSelectOption = (val: string) => {
|
||||
onSelect(val);
|
||||
combobox.closeDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
|
||||
<Combobox
|
||||
store={combobox}
|
||||
disabled={disabled}
|
||||
shadow="md"
|
||||
radius="md"
|
||||
withinPortal
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
combobox.closeDropdown();
|
||||
if (!selectedLabel) onSearchChange("");
|
||||
}}
|
||||
rightSection={
|
||||
isLoading ? <Loader size={14} /> : <Combobox.Chevron />
|
||||
}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{isLoading ? (
|
||||
<Combobox.Empty>Loading contracts...</Combobox.Empty>
|
||||
) : options.length === 0 ? (
|
||||
<Combobox.Empty>No contracts found</Combobox.Empty>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onClick={() => handleSelectOption(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, FileText, FileUp } from "lucide-react";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
type ContractDocuments,
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
function documentSettingCode(nationality: string | null | undefined): string {
|
||||
return nationality === "foreign"
|
||||
? "company_onboarding_documents_foreign"
|
||||
: "company_onboarding_documents_ethiopian";
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract intake documents step (doc §7 step 5). Mirrors the booking documents
|
||||
* step: the company's onboarding documents are shown read-only as a reference,
|
||||
* and the customer may attach commercial / framework documents that are saved
|
||||
* against the contract on submit. These are distinct from the post-sign
|
||||
* clearance documents uploaded later on `/contracts/:id/clearance` (Path B).
|
||||
*/
|
||||
export function StepDocuments({ form }: { form: ContractForm }) {
|
||||
const auth = useAuth();
|
||||
|
||||
const nationality = auth.company?.company?.nationality as
|
||||
| string
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const docSettingQuery = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: documentSettingCode(nationality) },
|
||||
}),
|
||||
);
|
||||
|
||||
const onboardingDocs = (() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
})();
|
||||
|
||||
const documents = (form.watch("documents") ?? {}) as ContractDocuments;
|
||||
|
||||
const setDocuments = (next: Record<string, File | File[] | null>) => {
|
||||
form.setValue("documents", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileUp size={22} />}
|
||||
title="Contract Documents"
|
||||
description="Attach the framework / commercial documents for this contract. They default to what you uploaded during onboarding — upload here only to override a document for this contract."
|
||||
/>
|
||||
|
||||
{onboardingDocs.length > 0 && (
|
||||
<Stack gap={10} mb="lg">
|
||||
<Text fz={13} fw={700} c="#10202F">
|
||||
On file from your onboarding
|
||||
</Text>
|
||||
{onboardingDocs.map((doc, i) => (
|
||||
<Group
|
||||
key={`${doc.url}-${i}`}
|
||||
gap={12}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-edr-border-0)",
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#EAF1FB",
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{doc.name}
|
||||
</Text>
|
||||
{doc.size ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatSize(doc.size)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
<Text size="xs" fw={600} c="#0A6F4D">
|
||||
On file
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Text fz={13} fw={700} c="#10202F" mb="sm">
|
||||
Documents for this contract
|
||||
</Text>
|
||||
|
||||
{docSettingQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : docSettingQuery.data ? (
|
||||
<SmartFileInput
|
||||
file={docSettingQuery.data}
|
||||
value={documents}
|
||||
onChange={setDocuments}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No document requirements are configured for your account. The
|
||||
documents on file from your onboarding will be attached to this
|
||||
contract automatically.
|
||||
</Text>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { Text } from "@mantine/core";
|
||||
import {
|
||||
ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
type OperationType,
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: OperationType;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
value: "import",
|
||||
title: "Import",
|
||||
description: "Cargo arriving into Ethiopia via Djibouti.",
|
||||
icon: <ArrowDownToLine className="h-5 w-5" />,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A6F4D",
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
title: "Export",
|
||||
description: "Cargo leaving Ethiopia bound for Djibouti.",
|
||||
icon: <ArrowUpFromLine className="h-5 w-5" />,
|
||||
iconBg: "#EAF1FB",
|
||||
iconColor: "#2E5B96",
|
||||
},
|
||||
{
|
||||
value: "intercity",
|
||||
title: "Intercity",
|
||||
description: "Domestic movement between Ethiopian yards.",
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
iconBg: "#F1ECFB",
|
||||
iconColor: "#6A40B8",
|
||||
},
|
||||
{
|
||||
value: "import_ff",
|
||||
title: "Import as FF",
|
||||
description: "Import handled on behalf of a client as a freight forwarder.",
|
||||
icon: <PackageOpen className="h-5 w-5" />,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A6F4D",
|
||||
},
|
||||
{
|
||||
value: "export_ff",
|
||||
title: "Export as FF",
|
||||
description: "Export handled on behalf of a client as a freight forwarder.",
|
||||
icon: <PackageCheck className="h-5 w-5" />,
|
||||
iconBg: "#EAF1FB",
|
||||
iconColor: "#2E5B96",
|
||||
},
|
||||
];
|
||||
|
||||
export function Step0OperationType({
|
||||
form,
|
||||
allowedOperations,
|
||||
onSelect,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
allowedOperations: OperationType[];
|
||||
onSelect?: (op: OperationType) => void;
|
||||
}) {
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Truck size={22} />}
|
||||
title="Operation Type"
|
||||
description="Choose what this contract is for. The options available reflect the operations your company is registered for."
|
||||
/>
|
||||
|
||||
{allowedOperations.length === 0 && (
|
||||
<AlertBox tone="error">
|
||||
Your company has no operational profile yet. Complete onboarding to
|
||||
register as an importer, exporter, or freight forwarder.
|
||||
</AlertBox>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="operationType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{OPTIONS.filter((opt) =>
|
||||
allowedOperations.includes(opt.value),
|
||||
).map((opt) => (
|
||||
<OptionCard
|
||||
key={opt.value}
|
||||
selected={field.value === opt.value}
|
||||
icon={opt.icon}
|
||||
iconBg={opt.iconBg}
|
||||
iconColor={opt.iconColor}
|
||||
title={opt.title}
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
onSelect?.(opt.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Text fz={12} c="edr-muted" mt={14}>
|
||||
Import and Export are stamped to your matching company profile; their
|
||||
documents are attached automatically at submission.
|
||||
</Text>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarClock,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Divider, Stack, Text } from "@mantine/core";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
AsyncComboboxField,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
interface PreviousContractOption {
|
||||
value: string;
|
||||
label: string;
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
|
||||
export function Step1ContractType({
|
||||
form,
|
||||
referenceData,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
}) {
|
||||
const contractType = form.watch("contractType");
|
||||
const previousContractRef = form.watch("previousContractRef");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const {
|
||||
data: contracts,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(
|
||||
api.contracts.list.queryOptions({
|
||||
input: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const contractOptions = useMemo<PreviousContractOption[]>(() => {
|
||||
if (!contracts) return [];
|
||||
return contracts.items
|
||||
.map((contract) => {
|
||||
const route = contract.routes?.[0];
|
||||
const origin = route?.originYard?.label ?? "Unknown";
|
||||
const destination = route?.destinationYard?.label ?? "Unknown";
|
||||
return {
|
||||
value: contract.reference,
|
||||
label: `${contract.reference} - Route: ${origin} to ${destination}`,
|
||||
contract,
|
||||
};
|
||||
})
|
||||
.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}, [contracts, searchQuery]);
|
||||
|
||||
const handleSelectContract = (reference: string) => {
|
||||
const selected = contractOptions.find((opt) => opt.value === reference);
|
||||
if (!selected) return;
|
||||
|
||||
form.setValue("previousContractRef", reference);
|
||||
|
||||
const contract = selected.contract;
|
||||
if (!contract) return;
|
||||
|
||||
// ── Service type ──
|
||||
const serviceId = contract.serviceTypeId;
|
||||
if (serviceId) form.setValue("serviceTypeId", serviceId);
|
||||
if (contract.paymentCurrency) {
|
||||
form.setValue(
|
||||
"paymentCurrency",
|
||||
contract.paymentCurrency as ContractFormValues["paymentCurrency"],
|
||||
);
|
||||
}
|
||||
|
||||
// ── First / last mile ──
|
||||
form.setValue("firstMile", {
|
||||
enabled: Boolean(contract.firstMilePickupAddress),
|
||||
pickUpAddress: contract.firstMilePickupAddress ?? "",
|
||||
lat: contract.firstMilePickupLat ?? null,
|
||||
lng: contract.firstMilePickupLng ?? null,
|
||||
});
|
||||
form.setValue("lastMile", {
|
||||
enabled: Boolean(contract.lastMileDeliveryAddress),
|
||||
deliveryAddress: contract.lastMileDeliveryAddress ?? "",
|
||||
lat: contract.lastMileDeliveryLat ?? null,
|
||||
lng: contract.lastMileDeliveryLng ?? null,
|
||||
});
|
||||
|
||||
// ── Equipment return / customs ──
|
||||
form.setValue(
|
||||
"equipmentReturn",
|
||||
contract.equipmentReturn === "without_return"
|
||||
? "without_return"
|
||||
: "with_return",
|
||||
);
|
||||
form.setValue(
|
||||
"customsClearingEnabled",
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
||||
|
||||
// ── Route (primary + extras) ──
|
||||
const routes = contract.routes ?? [];
|
||||
if (routes[0]) {
|
||||
form.setValue("originYard", routes[0].originYardId);
|
||||
form.setValue("destinationYard", routes[0].destinationYardId);
|
||||
}
|
||||
form.setValue(
|
||||
"extraRoutes",
|
||||
routes.slice(1).map((r) => ({
|
||||
originYard: r.originYardId,
|
||||
destinationYard: r.destinationYardId,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Cargo scope ──
|
||||
form.setValue(
|
||||
"cargoType",
|
||||
contract.freightType === "BULK" ? "bulk" : "container",
|
||||
);
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const sizes = scope
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
if (sizes.length > 0) form.setValue("enabledContainerSizes", sizes);
|
||||
const bulkScope = scope.find((s) => s.cargoTypeId);
|
||||
if (bulkScope?.cargoTypeId) {
|
||||
// Find the parent group for this commodity so the cascader prefills.
|
||||
const tree = referenceData?.cargo_type ?? [];
|
||||
for (const group of tree) {
|
||||
const child = group.children?.find(
|
||||
(c) => c.id === bulkScope.cargoTypeId,
|
||||
);
|
||||
if (child) {
|
||||
form.setValue("cargoTypePath", [group.id, child.id]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bulkScope.cargoFreeText) {
|
||||
form.setValue("cargoFreeText", bulkScope.cargoFreeText);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flags ──
|
||||
form.setValue("isHazardous", contract.isHazardous);
|
||||
form.setValue("isRefrigerated", contract.isReefer);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileSignature size={22} />}
|
||||
title="Contract Type"
|
||||
description="Choose a one-time contract or a general contract you can ship against multiple times over its validity window."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="contractKind"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value !== "general_contract"}
|
||||
icon={<CalendarClock className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="One-Time Contract"
|
||||
description="A single shipment cycle — one active booking at a time under this contract."
|
||||
onClick={() => field.onChange("one_time")}
|
||||
/>
|
||||
<OptionCard
|
||||
selected={field.value === "general_contract"}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
iconBg="#F1ECFB"
|
||||
iconColor="#6A40B8"
|
||||
title="General Contract"
|
||||
description="Ship multiple times over the validity window across one or more routes — no quantity cap."
|
||||
onClick={() => field.onChange("general_contract")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider my={24} />
|
||||
|
||||
<Text fw={700} fz={15} mb={4} style={{ color: "#10202F" }}>
|
||||
New or Renewal
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted" mb={16}>
|
||||
Start a fresh contract or renew an existing one to reuse its details.
|
||||
</Text>
|
||||
|
||||
<Controller
|
||||
name="contractType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value === "new"}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="New Contract"
|
||||
description="Create a fresh freight contract from scratch."
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
form.setValue("previousContractRef", "");
|
||||
}}
|
||||
/>
|
||||
|
||||
<OptionCard
|
||||
selected={field.value === "renewal"}
|
||||
icon={<RefreshCw className="h-5 w-5" />}
|
||||
iconBg="#EAF1FB"
|
||||
iconColor="#2E5B96"
|
||||
title="Contract Renewal"
|
||||
description="Pick a previous reference to auto-fill historical parameters."
|
||||
onClick={() => {
|
||||
field.onChange("renewal");
|
||||
form.clearErrors("contractType");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{contractType === "renewal" && (
|
||||
<Stack gap={12} mt={22}>
|
||||
{error && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load previous contracts. Please try again later.
|
||||
</AlertBox>
|
||||
)}
|
||||
<Controller
|
||||
name="previousContractRef"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<AsyncComboboxField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Previous Contract Reference Number"
|
||||
placeholder="Search by reference or route..."
|
||||
options={contractOptions}
|
||||
isLoading={isLoading}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={handleSelectContract}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{previousContractRef && (
|
||||
<AlertBox tone="success">
|
||||
<strong>Contract found.</strong> Route, service type, and cargo
|
||||
scope will be pre-filled.
|
||||
</AlertBox>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { fieldStyles, OptionFieldError, StepCard, StepHeader, StepLabel } from "./shared";
|
||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
||||
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
export function Step2ServiceType({
|
||||
form,
|
||||
referenceData,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
}) {
|
||||
const serviceTypeId = form.watch("serviceTypeId");
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === serviceTypeId,
|
||||
);
|
||||
|
||||
const { includesCustoms, includesFirstMile, includesLastMile } =
|
||||
serviceType ?? {};
|
||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||
|
||||
const prevServiceType = useRef(serviceType);
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"firstMile",
|
||||
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}, [includesFirstMile]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"lastMile",
|
||||
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}, [includesLastMile]);
|
||||
|
||||
// Customs clearance bundling is driven by the service: includesCustoms → the
|
||||
// contract follows Path B (clearance docs after sign); otherwise Path A.
|
||||
useEffect(() => {
|
||||
const prev = prevServiceType.current;
|
||||
prevServiceType.current = serviceType;
|
||||
if (!prev || prev === serviceType) return;
|
||||
|
||||
if (includesCustoms) {
|
||||
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
} else {
|
||||
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
}
|
||||
}, [serviceTypeId, form]);
|
||||
|
||||
const showServiceSections =
|
||||
serviceType != null || includesFirstMile || includesLastMile;
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Layers size={22} />}
|
||||
title="Service Type"
|
||||
description="Choose the service combination, then configure your trucking and customs options."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="serviceTypeId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{referenceData?.service
|
||||
.filter((s) => s.canBeBookedAlone)
|
||||
.map((s) => (
|
||||
<ServiceTypeCard
|
||||
key={s.id}
|
||||
selected={field.value === s.id}
|
||||
onClick={() => field.onChange(s.id)}
|
||||
title={s.serviceName}
|
||||
description={s.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PaymentCurrencyField control={form.control} />
|
||||
|
||||
{showServiceSections && (
|
||||
<Stack gap={12} mt={24}>
|
||||
<StepLabel>Trucking & customs options</StepLabel>
|
||||
|
||||
{includesFirstMile && (
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="First Mile — Pick-up"
|
||||
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
|
||||
checked={field.value ?? false}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue(
|
||||
"firstMile",
|
||||
{
|
||||
enabled: false,
|
||||
pickUpAddress: "",
|
||||
lat: null,
|
||||
lng: null,
|
||||
},
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{firstMileEnabled && (
|
||||
<Box mt="md">
|
||||
<Controller
|
||||
name="firstMile"
|
||||
control={form.control}
|
||||
render={({ field: mf, fieldState }) => (
|
||||
<LocationPicker
|
||||
label="Pick-up location"
|
||||
placeholder="Search the pick-up address…"
|
||||
error={
|
||||
(
|
||||
fieldState.error as {
|
||||
pickUpAddress?: { message?: string };
|
||||
}
|
||||
)?.pickUpAddress?.message
|
||||
}
|
||||
value={{
|
||||
address: mf.value?.pickUpAddress ?? "",
|
||||
lat: mf.value?.lat ?? null,
|
||||
lng: mf.value?.lng ?? null,
|
||||
}}
|
||||
onChange={(loc) =>
|
||||
mf.onChange({
|
||||
...mf.value,
|
||||
enabled: true,
|
||||
pickUpAddress: loc.address,
|
||||
lat: loc.lat,
|
||||
lng: loc.lng,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{includesLastMile && (
|
||||
<Controller
|
||||
name="lastMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Last Mile — Delivery"
|
||||
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
|
||||
checked={field.value ?? false}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue(
|
||||
"lastMile",
|
||||
{
|
||||
enabled: false,
|
||||
deliveryAddress: "",
|
||||
lat: null,
|
||||
lng: null,
|
||||
},
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
form.setValue("equipmentReturn", "with_return", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{lastMileEnabled && (
|
||||
<Box mt="md">
|
||||
<Controller
|
||||
name="lastMile"
|
||||
control={form.control}
|
||||
render={({ field: mf, fieldState }) => (
|
||||
<LocationPicker
|
||||
label="Delivery location"
|
||||
placeholder="Search the delivery address…"
|
||||
error={
|
||||
(
|
||||
fieldState.error as {
|
||||
deliveryAddress?: { message?: string };
|
||||
}
|
||||
)?.deliveryAddress?.message
|
||||
}
|
||||
value={{
|
||||
address: mf.value?.deliveryAddress ?? "",
|
||||
lat: mf.value?.lat ?? null,
|
||||
lng: mf.value?.lng ?? null,
|
||||
}}
|
||||
onChange={(loc) =>
|
||||
mf.onChange({
|
||||
...mf.value,
|
||||
enabled: true,
|
||||
deliveryAddress: loc.address,
|
||||
lat: loc.lat,
|
||||
lng: loc.lng,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{includesLastMile && lastMileEnabled && (
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Equipment Return"
|
||||
description={
|
||||
field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."
|
||||
}
|
||||
checked={field.value === "with_return"}
|
||||
onChange={(v) =>
|
||||
field.onChange(v ? "with_return" : "without_return")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{includesCustoms ? (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #CDEBDD",
|
||||
background: "#F6FBF8",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#ECF6F1",
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
Customs Clearing Service
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
Included with this service. After signing you will upload
|
||||
clearance documents and Global Logistics will handle the
|
||||
booking.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>
|
||||
<Group gap={6} align="center">
|
||||
<Info size={14} color="#0A6F4D" />
|
||||
<Text fz={12} fw={600} c="#0A6F4D">
|
||||
Included
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
) : (
|
||||
<Controller
|
||||
name="customsClearingAgent"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #E6ECF2",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#F1F4F7",
|
||||
color: "#64748B",
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
Customs Clearing Agent
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
Enter the name of your customs clearing agent for this
|
||||
contract.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<TextInput
|
||||
{...field}
|
||||
placeholder="Customs clearing agent name"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTypeCard({
|
||||
selected,
|
||||
onClick,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
padding: "12px 14px",
|
||||
transition: "all 140ms ease",
|
||||
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
|
||||
background: selected ? "#F4FBF7" : "#fff",
|
||||
boxShadow: selected
|
||||
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
|
||||
}}
|
||||
>
|
||||
<Group gap={11} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
flexShrink: 0,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: selected ? "#E3F4EC" : "#EEF0FB",
|
||||
color: selected ? "#0A6F4D" : "#4F46E5",
|
||||
}}
|
||||
>
|
||||
<Train size={17} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fz={13.5} fw={700} c="#10202F" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: selected ? "none" : "1.5px solid #CBD5E1",
|
||||
background: selected ? "#12B981" : "transparent",
|
||||
}}
|
||||
>
|
||||
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#ECF6F1" : "#F1F4F7",
|
||||
color: checked ? "#0A6F4D" : "#64748B",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, Package, Snowflake, Weight } from "lucide-react";
|
||||
import { Box, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import {
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
SelectField,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
/**
|
||||
* Contract cargo SCOPE step (doc §7 step 3). Defines WHAT cargo the contract
|
||||
* covers — never HOW MUCH:
|
||||
* - container: enabled sizes (20ft / 40ft) + optional commodity label
|
||||
* - bulk: a single cargo-type commodity
|
||||
* - shared hazardous / refrigerated billing flags
|
||||
* No quantity, VGM, or container-type detail is collected (that is captured at
|
||||
* booking time).
|
||||
*/
|
||||
export function Step3CargoScope({
|
||||
form,
|
||||
referenceData,
|
||||
isLoading,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const cargoType = form.watch("cargoType");
|
||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||
const parentId = cargoTypePath[0];
|
||||
|
||||
// Reset the commodity child only when the parent group really changes.
|
||||
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||
useEffect(() => {
|
||||
if (prevParentIdRef.current === parentId) return;
|
||||
const switchedToAnotherParent =
|
||||
!!prevParentIdRef.current && !!parentId;
|
||||
prevParentIdRef.current = parentId;
|
||||
if (switchedToAnotherParent) {
|
||||
form.setValue("cargoTypePath", [parentId as string, ""], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}, [parentId, form]);
|
||||
|
||||
// Clear reefer when switching to container (container reefer is per-booking).
|
||||
useEffect(() => {
|
||||
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
|
||||
form.setValue("isRefrigerated", false);
|
||||
}
|
||||
}, [cargoType, form]);
|
||||
|
||||
const freightTypeGroups = useMemo(() => {
|
||||
if (!referenceData?.cargo_type) return [];
|
||||
return referenceData.cargo_type.filter(
|
||||
(g) => g.code !== "CONTAINER" && !/container/i.test(g.name),
|
||||
);
|
||||
}, [referenceData]);
|
||||
|
||||
const freightTypeOptions = useMemo(
|
||||
() => freightTypeGroups.map((g) => ({ value: g.id, label: g.name })),
|
||||
[freightTypeGroups],
|
||||
);
|
||||
|
||||
const commodityOptions = useMemo(() => {
|
||||
if (!referenceData?.cargo_type || !parentId) return [];
|
||||
const group = referenceData.cargo_type.find((g) => g.id === parentId);
|
||||
return (
|
||||
group?.children?.map((c) => ({ value: c.id, label: c.name })) ?? []
|
||||
);
|
||||
}, [referenceData, parentId]);
|
||||
|
||||
// Commodity options for the optional container commodity label.
|
||||
const containerCommodityOptions = useMemo(() => {
|
||||
if (!referenceData?.cargo_type) return [];
|
||||
return referenceData.cargo_type.flatMap((g) =>
|
||||
(g.children ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${g.name} — ${c.name}`,
|
||||
})),
|
||||
);
|
||||
}, [referenceData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Scope"
|
||||
description="Define what cargo this contract covers — sizes and commodity, no quantities."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Skeleton height={14} w={96} radius="sm" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="lg" />
|
||||
<Skeleton height={96} radius="lg" />
|
||||
</div>
|
||||
<Skeleton height={44} radius="md" />
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Scope"
|
||||
description="Define what cargo this contract covers. Quantities, container numbers and weights are captured later at booking."
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={cargoType === "container"}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="Containerized"
|
||||
description="Containerized cargo (20ft / 40ft)."
|
||||
onClick={() => {
|
||||
field.onChange("container");
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<OptionCard
|
||||
selected={cargoType === "bulk"}
|
||||
icon={<Weight className="h-5 w-5" />}
|
||||
iconBg="#FDF3E0"
|
||||
iconColor="#C77F09"
|
||||
title="General Cargo"
|
||||
description="Bulk commodities or break-bulk cargo."
|
||||
onClick={() => {
|
||||
field.onChange("bulk");
|
||||
form.setValue("enabledContainerSizes", [], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Container scope: enabled sizes + optional commodity label. */}
|
||||
{cargoType === "container" && (
|
||||
<Stack gap={14} mt={18}>
|
||||
<Controller
|
||||
name="enabledContainerSizes"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selected = field.value ?? [];
|
||||
const toggle = (size: "20ft" | "40ft") => {
|
||||
const next = selected.includes(size)
|
||||
? selected.filter((s) => s !== size)
|
||||
: [...selected, size];
|
||||
field.onChange(next);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<StepLabel>Container sizes in scope *</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={10}>
|
||||
Enable each container size this contract may ship.
|
||||
</Text>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{CONTAINER_SIZES.map((size) => (
|
||||
<OptionCard
|
||||
key={size}
|
||||
selected={selected.includes(size)}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title={
|
||||
size === "20ft"
|
||||
? "20ft Container (TEU)"
|
||||
: "40ft Container (FEU)"
|
||||
}
|
||||
description={
|
||||
size === "20ft"
|
||||
? "Twenty-foot equivalent unit."
|
||||
: "Forty-foot equivalent unit."
|
||||
}
|
||||
onClick={() => toggle(size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{containerCommodityOptions.length > 0 && (
|
||||
<Controller
|
||||
name="cargoCommodityId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Commodity (optional)"
|
||||
placeholder="Select a commodity for the contract document…"
|
||||
data={containerCommodityOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
|
||||
{cargoType === "bulk" && (
|
||||
<Stack gap={12} mt={18}>
|
||||
{freightTypeOptions.length > 0 ? (
|
||||
<Controller
|
||||
name="cargoTypePath.0"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Bulk Cargo Type *"
|
||||
placeholder="Select freight type..."
|
||||
data={freightTypeOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No freight types available.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{parentId && commodityOptions.length > 0 && (
|
||||
<Controller
|
||||
name="cargoTypePath.1"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Commodity *"
|
||||
placeholder="Select type..."
|
||||
data={commodityOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Shared billing flags. */}
|
||||
<Box mt={22}>
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge as a per-container unit rate."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a reefer surcharge."
|
||||
checked={field.value}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Box, Button, Group, Skeleton, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
Plus,
|
||||
Route as RouteIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { getRouteDirection } from "./helpers";
|
||||
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
export function Step4Route({
|
||||
form,
|
||||
referenceData,
|
||||
isLoading,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const operationType = form.watch("operationType");
|
||||
const isGeneralContract = form.watch("contractKind") === "general_contract";
|
||||
|
||||
const { originCountry, destinationCountry } = useMemo(() => {
|
||||
switch (operationType) {
|
||||
case "import":
|
||||
case "import_ff":
|
||||
return { originCountry: "Djibouti", destinationCountry: "Ethiopia" };
|
||||
case "export":
|
||||
case "export_ff":
|
||||
return { originCountry: "Ethiopia", destinationCountry: "Djibouti" };
|
||||
case "intercity":
|
||||
return { originCountry: "Ethiopia", destinationCountry: "Ethiopia" };
|
||||
default:
|
||||
return { originCountry: null, destinationCountry: null };
|
||||
}
|
||||
}, [operationType]);
|
||||
|
||||
const {
|
||||
fields: extraRoutes,
|
||||
append: appendRoute,
|
||||
remove: removeRoute,
|
||||
} = useFieldArray({ control: form.control, name: "extraRoutes" });
|
||||
|
||||
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||
}, [referenceData]);
|
||||
|
||||
const yardsForSide = useCallback(
|
||||
(country: string | null, excludeYardId: string) =>
|
||||
yardOptions
|
||||
.filter((o) => o.value !== excludeYardId)
|
||||
.filter((o) => {
|
||||
if (!country) return true;
|
||||
const yard = referenceData?.yard.find((y) => y.id === o.value);
|
||||
return yard?.country === country;
|
||||
}),
|
||||
[yardOptions, referenceData],
|
||||
);
|
||||
|
||||
const originData = useMemo(
|
||||
() => yardsForSide(originCountry, destinationYard),
|
||||
[yardsForSide, originCountry, destinationYard],
|
||||
);
|
||||
const destData = useMemo(
|
||||
() => yardsForSide(destinationCountry, originYard),
|
||||
[yardsForSide, destinationCountry, originYard],
|
||||
);
|
||||
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
|
||||
// Clear yards that no longer match the operation's required country.
|
||||
useEffect(() => {
|
||||
if (originCountry && origin && origin.country !== originCountry) {
|
||||
form.setValue("originYard", "");
|
||||
}
|
||||
}, [originCountry, origin, form]);
|
||||
useEffect(() => {
|
||||
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
||||
form.setValue("destinationYard", "");
|
||||
}
|
||||
}, [destinationCountry, dest, form]);
|
||||
|
||||
useEffect(() => {
|
||||
watchedExtraRoutes.forEach((route, i) => {
|
||||
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
|
||||
if (originCountry && ro && ro.country !== originCountry) {
|
||||
form.setValue(`extraRoutes.${i}.originYard`, "");
|
||||
}
|
||||
const rd = referenceData?.yard.find(
|
||||
(y) => y.id === route?.destinationYard,
|
||||
);
|
||||
if (destinationCountry && rd && rd.country !== destinationCountry) {
|
||||
form.setValue(`extraRoutes.${i}.destinationYard`, "");
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [originCountry, destinationCountry, referenceData, form]);
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
};
|
||||
const directionLabel: Record<string, string> = {
|
||||
EXPORT: "Export workflow (inside country to outside country)",
|
||||
IMPORT: "Import workflow (outside country to inside country)",
|
||||
DOMESTIC: "Domestic corridor",
|
||||
};
|
||||
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
const todayISODate = useMemo(() => {
|
||||
const now = new Date();
|
||||
const tz = now.getTimezoneOffset() * 60000;
|
||||
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<RouteIcon size={22} />}
|
||||
title="Route"
|
||||
description="Choose the origin and destination yards this contract covers, plus a non-binding estimated shipment date."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin Yard *"
|
||||
placeholder="Select origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={originData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination Yard *"
|
||||
placeholder="Select destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={destData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{direction && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
{directionLabel[direction]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Estimated shipment date — a planning value for every contract. The
|
||||
binding scheduled date is captured later at booking time. */}
|
||||
<Box style={{ maxWidth: 280 }}>
|
||||
<Controller
|
||||
name="estimatedShipmentDate"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Estimated shipment date *"
|
||||
description="A non-binding planning estimate. The actual shipment date is chosen when you book against this contract."
|
||||
min={todayISODate}
|
||||
leftSection={<CalendarDays size={16} />}
|
||||
error={fieldState.error?.message}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGeneralContract && !isLoading && (
|
||||
<Box mt={18}>
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<StepLabel>Additional contract routes</StepLabel>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Plus size={14} />}
|
||||
disabled={stationSelectDisabled}
|
||||
onClick={() =>
|
||||
appendRoute({ originYard: "", destinationYard: "" })
|
||||
}
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz={12} c="#6B7C8E" mb={12}>
|
||||
A general contract can cover several routes. The route above is your
|
||||
primary route; add more origin–destination routes the contract
|
||||
should cover.
|
||||
</Text>
|
||||
<Stack gap={12}>
|
||||
{extraRoutes.map((rf, i) => {
|
||||
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
|
||||
const rowDestination =
|
||||
watchedExtraRoutes[i]?.destinationYard ?? "";
|
||||
const rowOriginData = yardsForSide(originCountry, rowDestination);
|
||||
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
|
||||
return (
|
||||
<Group
|
||||
key={rf.id}
|
||||
gap={10}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.originYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin"
|
||||
placeholder="Origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowOriginData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.destinationYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination"
|
||||
placeholder="Destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowDestData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
mt={24}
|
||||
px={6}
|
||||
onClick={() => removeRoute(i)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stack gap={8}>
|
||||
<Skeleton height={12} w={80} radius="sm" />
|
||||
<Skeleton height={40} radius="md" />
|
||||
</Stack>
|
||||
<Stack gap={8}>
|
||||
<Skeleton height={12} w={96} radius="sm" />
|
||||
<Skeleton height={40} radius="md" />
|
||||
</Stack>
|
||||
</div>
|
||||
<Skeleton height={32} radius="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Package,
|
||||
Pencil,
|
||||
Route,
|
||||
Send,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { formatRateUnit } from "./unit-rates";
|
||||
|
||||
export const REVIEW_STEP_TARGETS = {
|
||||
contract: 1,
|
||||
service: 2,
|
||||
cargo: 3,
|
||||
route: 4,
|
||||
schedule: 4,
|
||||
documents: 6,
|
||||
} as const;
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
function OverviewSection({
|
||||
icon,
|
||||
title,
|
||||
onEdit,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex items-center justify-center rounded-lg"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Text fw={700} size="sm" c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
size="compact-xs"
|
||||
leftSection={<Pencil size={13} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
className="tracking-wide"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} ta="right" maw="60%">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessItem({ done, label }: { done: boolean; label: string }) {
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{done ? (
|
||||
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<Circle size={18} className="shrink-0 text-gray-300" />
|
||||
)}
|
||||
<Text size="sm" c={done ? "dark" : "dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only UNIT-RATE pricing panel (doc §9.1). Shows per-container / per-ton /
|
||||
* per-item rates with NO total — quantities are unknown at the contract stage.
|
||||
*/
|
||||
function UnitRatePanel({
|
||||
currency,
|
||||
lineItems,
|
||||
}: {
|
||||
currency: string;
|
||||
lineItems: Freight.ContractUnitRateLineItem[];
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-edr-border-0)",
|
||||
background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
mb="xs"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Estimated unit rates — the final amount is calculated at booking from the
|
||||
quantities you ship.
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{lineItems.map((item) => (
|
||||
<Group
|
||||
key={item.code}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" c="#10202F" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.containerSize && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.containerSize}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{item.unitPrice.toLocaleString()} {currency}{" "}
|
||||
<Text span fz={12} fw={600} c="edr-muted">
|
||||
/ {formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{lineItems.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No unit rates returned. Generate the quotation to see your pricing
|
||||
schedule.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step8Review({
|
||||
form,
|
||||
setStep,
|
||||
direction,
|
||||
referenceData,
|
||||
onboardingDocs = [],
|
||||
pricing,
|
||||
onSaveDraft,
|
||||
onSubmit,
|
||||
saveDraftPending = false,
|
||||
submitPending = false,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
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;
|
||||
onSubmit?: () => void;
|
||||
saveDraftPending?: boolean;
|
||||
submitPending?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === values.serviceTypeId,
|
||||
);
|
||||
|
||||
const isGeneralContract = values.contractKind === "general_contract";
|
||||
|
||||
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;
|
||||
const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs;
|
||||
|
||||
const cargoValue = (() => {
|
||||
if (values.cargoType === "container") {
|
||||
const sizes = (values.enabledContainerSizes ?? []).join(", ");
|
||||
return sizes ? `Container — ${sizes}` : "Container freight";
|
||||
}
|
||||
if (!referenceData) return "";
|
||||
const path = values.cargoTypePath ?? [];
|
||||
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
|
||||
if (!group) return "";
|
||||
const child = group.children?.find((c) => c.id === path[1]);
|
||||
return child ? `${group.name} — ${child.name}` : group.name;
|
||||
})();
|
||||
|
||||
const originYardName =
|
||||
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
|
||||
values.originYard;
|
||||
const destinationYardName =
|
||||
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
|
||||
values.destinationYard;
|
||||
|
||||
const scheduleLabel = values.estimatedShipmentDate
|
||||
? format(new Date(values.estimatedShipmentDate), "EEEE, MMM d, yyyy")
|
||||
: "—";
|
||||
|
||||
const directionLabel = direction
|
||||
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
||||
: "—";
|
||||
|
||||
const routesCount = 1 + (values.extraRoutes?.filter(
|
||||
(r) => r.originYard && r.destinationYard,
|
||||
).length ?? 0);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<StepHeader
|
||||
icon={<ClipboardCheck size={22} />}
|
||||
title="Review & Submit"
|
||||
description="Review your contract overview and unit-rate quotation before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{/* Left — contract summary */}
|
||||
<Stack gap="md" className="min-w-0 flex-1">
|
||||
<Paper
|
||||
radius={20}
|
||||
p="lg"
|
||||
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
className="tracking-wider"
|
||||
>
|
||||
Contract overview
|
||||
</Text>
|
||||
<Text fw={800} size="xl" c="#10202F">
|
||||
{isGeneralContract ? "General Contract" : "One-Time Contract"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{serviceType?.serviceName ?? "—"} · {originYardName} →{" "}
|
||||
{destinationYardName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="lg" variant="light" color="edr-green" radius="md">
|
||||
{directionLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{pricing && (
|
||||
<UnitRatePanel
|
||||
currency={pricing.currency}
|
||||
lineItems={pricing.lineItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OverviewSection
|
||||
icon={<Package size={18} />}
|
||||
title="Contract & Service"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
|
||||
>
|
||||
<DetailRow
|
||||
label="Kind"
|
||||
value={isGeneralContract ? "General Contract" : "One-Time"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Contract"
|
||||
value={values.contractType === "new" ? "New" : "Renewal"}
|
||||
/>
|
||||
{values.contractType === "renewal" && values.previousContractRef && (
|
||||
<DetailRow
|
||||
label="Previous ref"
|
||||
value={values.previousContractRef}
|
||||
/>
|
||||
)}
|
||||
<DetailRow label="Service" value={serviceType?.serviceName ?? ""} />
|
||||
<DetailRow
|
||||
label="Payment currency"
|
||||
value={values.paymentCurrency ?? "USD"}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Route size={18} />}
|
||||
title="Route"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
|
||||
>
|
||||
<DetailRow
|
||||
label="Primary corridor"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
{isGeneralContract && (
|
||||
<DetailRow label="Routes covered" value={String(routesCount)} />
|
||||
)}
|
||||
<DetailRow label="Trade direction" value={directionLabel} />
|
||||
<DetailRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[
|
||||
values.isHazardous && "Hazardous",
|
||||
values.isRefrigerated && "Refrigerated",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Truck size={18} />}
|
||||
title="Logistics"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
|
||||
>
|
||||
<DetailRow
|
||||
label="First mile"
|
||||
value={
|
||||
values.firstMile.enabled
|
||||
? values.firstMile.pickUpAddress
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Last mile"
|
||||
value={
|
||||
values.lastMile.enabled
|
||||
? values.lastMile.deliveryAddress
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Equipment return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With return"
|
||||
: "Without return"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Customs clearing"
|
||||
value={
|
||||
values.customsClearingEnabled
|
||||
? values.customsClearingAgent
|
||||
? `Enabled — agent: ${values.customsClearingAgent}`
|
||||
: "Enabled (Global Logistics)"
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Calendar size={18} />}
|
||||
title="Schedule"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
||||
>
|
||||
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<Package size={18} />}
|
||||
title="Cargo scope"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
|
||||
>
|
||||
<DetailRow label="Cargo" value={cargoValue} />
|
||||
{values.cargoType === "container" &&
|
||||
(values.enabledContainerSizes ?? []).length > 0 && (
|
||||
<Table mt="sm" withTableBorder fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container size in scope</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(values.enabledContainerSizes ?? []).map((s) => (
|
||||
<Table.Tr key={s}>
|
||||
<Table.Td>{s}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
icon={<FileText size={18} />}
|
||||
title="Documents"
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{onboardingDocsCount > 0 ? (
|
||||
docsToShow.map((doc, i) => (
|
||||
<Group
|
||||
key={`${doc.name}-${i}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-emerald-600 shrink-0"
|
||||
/>
|
||||
<Text size="sm" className="truncate max-w-[60%]">
|
||||
{doc.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Attached
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Circle size={16} className="text-gray-300 shrink-0" />
|
||||
<Text size="sm" c="dimmed">
|
||||
No documents attached yet.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
These documents will be attached to this contract.
|
||||
</Text>
|
||||
</OverviewSection>
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for EDR operations…"
|
||||
rows={3}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Right — sticky actions */}
|
||||
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
|
||||
<Stack gap="md">
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text fw={800} size="sm" mb="md" c="#10202F">
|
||||
Submission readiness
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<ReadinessItem
|
||||
done={Boolean(values.serviceTypeId)}
|
||||
label="Service configured"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.paymentCurrency)}
|
||||
label="Payment currency selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.originYard && values.destinationYard)}
|
||||
label="Route selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.estimatedShipmentDate)}
|
||||
label="Estimated date selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={
|
||||
values.cargoType === "container"
|
||||
? (values.enabledContainerSizes ?? []).length > 0
|
||||
: Boolean(values.cargoTypePath?.[1])
|
||||
}
|
||||
label="Cargo scope complete"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={onboardingDocsCount > 0}
|
||||
label="Documents attached"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{pricing
|
||||
? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review."
|
||||
: "Ready to submit. You'll review the unit-rate quotation before final submission."}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
size="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onSubmit}
|
||||
loading={submitPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
{pricing ? "Approve quotation & submit" : "Submit"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
fullWidth
|
||||
onClick={onSaveDraft}
|
||||
loading={saveDraftPending}
|
||||
disabled={submitPending}
|
||||
>
|
||||
Save as draft
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { Step0OperationType } from "./step0-operation-type";
|
||||
export { Step1ContractType } from "./step1-contract-type";
|
||||
export { Step2ServiceType } from "./step2-service-type";
|
||||
export { Step3CargoScope } from "./step3-cargo-scope";
|
||||
export { Step4Route } from "./step4-route";
|
||||
export { StepDocuments } from "./step-documents";
|
||||
export { Step8Review } from "./step8-review";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** Human-readable label for a contract unit-rate's charge unit. */
|
||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||
const map: Record<string, string> = {
|
||||
per_container: "container",
|
||||
per_ton: "ton",
|
||||
per_item: "item",
|
||||
per_km: "km",
|
||||
flat: "flat",
|
||||
};
|
||||
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type {
|
||||
ContractFormInputValues,
|
||||
ContractFormValues,
|
||||
} from "./schema";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
ContractFormInputValues,
|
||||
any,
|
||||
ContractFormValues
|
||||
>;
|
||||
|
||||
const STORAGE_KEY = "edr.freight.contractDraft.v1";
|
||||
const WRITE_DELAY_MS = 400;
|
||||
|
||||
interface ContractDraftSnapshot {
|
||||
step: number;
|
||||
values: Partial<ContractFormInputValues>;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** Files can't be serialized — strip the documents map before persisting. */
|
||||
function stripUnserializable(
|
||||
values: ContractFormInputValues,
|
||||
): Partial<ContractFormInputValues> {
|
||||
const { documents: _documents, ...rest } = values;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function readDraft(): ContractDraftSnapshot | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as ContractDraftSnapshot;
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.values) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearContractDraft(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors (private mode, quota, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the in-progress contract wizard to localStorage so a refresh doesn't
|
||||
* lose the customer's work, and restores it on the next visit. Mirrors the
|
||||
* booking wizard's `useBookingDraft`.
|
||||
*/
|
||||
export function useContractDraft({
|
||||
form,
|
||||
step,
|
||||
setStep,
|
||||
fresh,
|
||||
}: {
|
||||
form: ContractForm;
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
fresh: boolean;
|
||||
}): { clearDraft: () => void } {
|
||||
const restoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
|
||||
if (fresh) {
|
||||
clearContractDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = readDraft();
|
||||
if (!draft) return;
|
||||
|
||||
form.reset(
|
||||
{ ...form.getValues(), ...draft.values },
|
||||
{ keepDefaultValues: true },
|
||||
);
|
||||
if (typeof draft.step === "number") setStep(draft.step);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stepRef = useRef(step);
|
||||
stepRef.current = step;
|
||||
|
||||
const write = () => {
|
||||
try {
|
||||
const snapshot: ContractDraftSnapshot = {
|
||||
step: stepRef.current,
|
||||
values: stripUnserializable(form.getValues()),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) return;
|
||||
const sub = form.watch(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(write, WRITE_DELAY_MS);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) return;
|
||||
write();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
return { clearDraft: clearContractDraft };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { DeepPartial, Path } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
// Shipment booking under a contract (doc §8.1, Path A customer). Captures the
|
||||
// EXECUTION details the contract scope deliberately omits: a binding scheduled
|
||||
// date, container quantities + per-unit numbers/seals/VGM, or bulk tonnage.
|
||||
export const SHIPMENT_STEPS = [
|
||||
{ id: 0, label: "Route", short: "Route" },
|
||||
{ id: 1, label: "Schedule", short: "Schedule" },
|
||||
{ id: 2, label: "Cargo Details", short: "Cargo" },
|
||||
{ id: 3, label: "Review", short: "Review" },
|
||||
] as const;
|
||||
|
||||
const containerUnitSchema = z.object({
|
||||
containerNumber: z.string().min(1, "Container number is required."),
|
||||
sealNumber: z.string().default(""),
|
||||
vgmTons: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "VGM is required.")
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."),
|
||||
});
|
||||
|
||||
const containerLineSchema = z.object({
|
||||
containerSize: z.enum(["20ft", "40ft"]),
|
||||
quantity: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "Quantity is required.")
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
|
||||
hazardousQuantity: z.string().default("0"),
|
||||
reeferQuantity: z.string().default("0"),
|
||||
units: z.array(containerUnitSchema).default([]),
|
||||
});
|
||||
|
||||
export const shipmentFormSchema = z
|
||||
.object({
|
||||
contractRouteId: z.string().default(""),
|
||||
scheduledDate: z.string().default(""),
|
||||
// Container shipment lines (one per size). Empty for bulk contracts.
|
||||
containers: z.array(containerLineSchema).default([]),
|
||||
// Bulk shipment amount — tons or item count depending on the commodity.
|
||||
cargoWeightTons: z.string().default(""),
|
||||
itemCount: z.string().default(""),
|
||||
bulkHazardousQuantity: z.string().default("0"),
|
||||
notes: z.string().default(""),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.scheduledDate.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["scheduledDate"],
|
||||
message: "Select a shipment date.",
|
||||
});
|
||||
}
|
||||
data.containers.forEach((line, i) => {
|
||||
const qty = Number(line.quantity || 0);
|
||||
// Each container must have one unit with a number + VGM.
|
||||
if (qty >= 1 && line.units.length < qty) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "units"],
|
||||
message: `Enter details for all ${qty} container(s).`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type ShipmentFormValues = z.infer<typeof shipmentFormSchema>;
|
||||
export type ShipmentFormInputValues = z.input<typeof shipmentFormSchema>;
|
||||
|
||||
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
contractRouteId: "",
|
||||
scheduledDate: "",
|
||||
containers: [],
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
bulkHazardousQuantity: "0",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export const shipmentStepFields: Record<
|
||||
number,
|
||||
Array<Path<ShipmentFormValues>>
|
||||
> = {
|
||||
0: ["contractRouteId"],
|
||||
1: ["scheduledDate"],
|
||||
2: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
|
||||
3: ["notes"],
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { ShipmentFormValues } from "./schema";
|
||||
|
||||
export interface ShipmentTotalLine {
|
||||
label: string;
|
||||
unitPrice: number;
|
||||
unit: Freight.ContractRateUnit | string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ShipmentTotal {
|
||||
currency: string;
|
||||
lines: ShipmentTotalLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the booking total client-side from the contract's frozen unit rates ×
|
||||
* the quantities the customer enters (doc §9.2). This is an estimate shown in
|
||||
* the review step; the server recomputes the authoritative total on submit.
|
||||
*/
|
||||
export function computeShipmentTotal(
|
||||
contract: Freight.IContract,
|
||||
values: ShipmentFormValues,
|
||||
): ShipmentTotal {
|
||||
const breakdown = contract.pricingBreakdown;
|
||||
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
|
||||
const items = breakdown?.lineItems ?? [];
|
||||
const lines: ShipmentTotalLine[] = [];
|
||||
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
|
||||
const rateFor = (
|
||||
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
|
||||
) => items.find(predicate);
|
||||
|
||||
if (isContainer) {
|
||||
let hazardTotalQty = 0;
|
||||
let reeferTotalQty = 0;
|
||||
|
||||
for (const line of values.containers) {
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (qty <= 0) continue;
|
||||
const rate =
|
||||
rateFor(
|
||||
(i) =>
|
||||
i.containerSize === line.containerSize &&
|
||||
i.unit === "per_container" &&
|
||||
!i.conditionalOn,
|
||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||
if (rate) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
hazardTotalQty += Number(line.hazardousQuantity || 0);
|
||||
reeferTotalQty += Number(line.reeferQuantity || 0);
|
||||
}
|
||||
|
||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
|
||||
if (hz) {
|
||||
lines.push({
|
||||
label: hz.label,
|
||||
unitPrice: hz.unitPrice,
|
||||
unit: hz.unit,
|
||||
quantity: hazardTotalQty,
|
||||
amount: hz.unitPrice * hazardTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (contract.isReefer && reeferTotalQty > 0) {
|
||||
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
|
||||
if (rf) {
|
||||
lines.push({
|
||||
label: rf.label,
|
||||
unitPrice: rf.unitPrice,
|
||||
unit: rf.unit,
|
||||
quantity: reeferTotalQty,
|
||||
amount: rf.unitPrice * reeferTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
const rate =
|
||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||
return { currency, lines, total };
|
||||
}
|
||||
@@ -14,9 +14,14 @@ import {
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
import {
|
||||
bookingOrdersService,
|
||||
CreateBookingOrderPayload,
|
||||
} from "./booking-orders.service";
|
||||
contractsService,
|
||||
ContractListFilter,
|
||||
CreateContractPayload,
|
||||
UpdateContractPayload,
|
||||
ContractDocuments,
|
||||
GenerateContractPriceResponse,
|
||||
SubmitContractResponse,
|
||||
} from "./contracts.service";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import {
|
||||
paymentsService,
|
||||
@@ -316,32 +321,113 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
bookingOrders: {
|
||||
listByContract: endpoint<
|
||||
{ contractBookingId: string },
|
||||
Freight.IBookingOrder[]
|
||||
>("booking-orders", "listByContract", ({ contractBookingId }) =>
|
||||
bookingOrdersService.listByContract(contractBookingId),
|
||||
contracts: {
|
||||
list: endpoint<
|
||||
ContractListFilter | void,
|
||||
PaginatedResponse<Freight.IContract>
|
||||
>("contracts", "list", contractsService.list),
|
||||
|
||||
listMy: endpoint<
|
||||
ContractListFilter | void,
|
||||
PaginatedResponse<Freight.IContract>
|
||||
>("contracts", "listMy", contractsService.listMy),
|
||||
|
||||
get: endpoint<{ id: string }, Freight.IContract>(
|
||||
"contracts",
|
||||
"get",
|
||||
({ id }) => contractsService.get(id),
|
||||
),
|
||||
|
||||
pool: endpoint<
|
||||
{ contractBookingId: string },
|
||||
Freight.ContractQuantityLine[]
|
||||
>("booking-orders", "pool", ({ contractBookingId }) =>
|
||||
bookingOrdersService.pool(contractBookingId),
|
||||
create: endpoint<
|
||||
{ payload: CreateContractPayload; documents?: ContractDocuments },
|
||||
Freight.IContract
|
||||
>("contracts", "create", ({ payload, documents }) =>
|
||||
contractsService.create(payload, documents),
|
||||
),
|
||||
|
||||
routes: endpoint<
|
||||
{ contractBookingId: string },
|
||||
Freight.ContractRouteLine[]
|
||||
>("booking-orders", "routes", ({ contractBookingId }) =>
|
||||
bookingOrdersService.routes(contractBookingId),
|
||||
update: endpoint<
|
||||
{
|
||||
id: string;
|
||||
dto: UpdateContractPayload;
|
||||
documents?: ContractDocuments;
|
||||
},
|
||||
Freight.IContract
|
||||
>("contracts", "update", ({ id, dto, documents }) =>
|
||||
contractsService.update(id, dto, documents),
|
||||
),
|
||||
|
||||
create: endpoint<CreateBookingOrderPayload, Freight.IBookingOrder>(
|
||||
"booking-orders",
|
||||
"create",
|
||||
(payload) => bookingOrdersService.create(payload),
|
||||
remove: endpoint<{ id: string }, void>("contracts", "remove", ({ id }) =>
|
||||
contractsService.remove(id),
|
||||
),
|
||||
|
||||
uploadDocuments: endpoint<
|
||||
{ id: string; files: ContractDocuments },
|
||||
Freight.IContract
|
||||
>("contracts", "uploadDocuments", ({ id, files }) =>
|
||||
contractsService.uploadDocuments(id, files),
|
||||
),
|
||||
|
||||
generatePrice: endpoint<{ id: string }, GenerateContractPriceResponse>(
|
||||
"contracts",
|
||||
"generatePrice",
|
||||
({ id }) => contractsService.generatePrice(id),
|
||||
),
|
||||
|
||||
submit: endpoint<{ id: string }, SubmitContractResponse>(
|
||||
"contracts",
|
||||
"submit",
|
||||
({ id }) => contractsService.submit(id),
|
||||
),
|
||||
|
||||
confirmSubmit: endpoint<{ id: string }, SubmitContractResponse>(
|
||||
"contracts",
|
||||
"confirmSubmit",
|
||||
({ id }) => contractsService.confirmSubmit(id),
|
||||
),
|
||||
|
||||
generateContract: endpoint<{ id: string }, Freight.IContract>(
|
||||
"contracts",
|
||||
"generateContract",
|
||||
({ id }) => contractsService.generateContract(id),
|
||||
),
|
||||
|
||||
renew: endpoint<
|
||||
{ id: string; dto: Freight.RenewContractDto },
|
||||
Freight.IContract
|
||||
>("contracts", "renew", ({ id, dto }) => contractsService.renew(id, dto)),
|
||||
|
||||
getClearance: endpoint<{ id: string }, Freight.ContractClearanceView>(
|
||||
"contracts",
|
||||
"getClearance",
|
||||
({ id }) => contractsService.getClearance(id),
|
||||
),
|
||||
|
||||
uploadClearanceDocuments: endpoint<
|
||||
{ id: string; files: Record<string, File | null> },
|
||||
Freight.ContractClearanceView
|
||||
>("contracts", "uploadClearanceDocuments", ({ id, files }) =>
|
||||
contractsService.uploadClearanceDocuments(id, files),
|
||||
),
|
||||
|
||||
createBookingUnderContract: endpoint<
|
||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||
Freight.IBooking
|
||||
>("contracts", "createBookingUnderContract", ({ id, dto }) =>
|
||||
contractsService.createBookingUnderContract(id, dto),
|
||||
),
|
||||
|
||||
getContractMilestones: endpoint<
|
||||
{ id: string },
|
||||
Freight.IClearanceMilestone[]
|
||||
>("contracts", "getContractMilestones", ({ id }) =>
|
||||
contractsService.getContractMilestones(id),
|
||||
),
|
||||
|
||||
getBookingMilestones: endpoint<
|
||||
{ bookingId: string },
|
||||
Freight.IClearanceMilestone[]
|
||||
>("contracts", "getBookingMilestones", ({ bookingId }) =>
|
||||
contractsService.getBookingMilestones(bookingId),
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user