mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||||
import { Contract } from '../modules/contracts/entities/contract.entity';
|
import {
|
||||||
|
Contract,
|
||||||
|
ContractDocumentSnapshot,
|
||||||
|
} from '../modules/contracts/entities/contract.entity';
|
||||||
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||||||
import {
|
import {
|
||||||
ContractSignature,
|
ContractSignature,
|
||||||
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
|
|||||||
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
|
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
|
||||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||||
import { getTemplateMeta } from './contract-template.registry';
|
import { getTemplateMeta } from './contract-template.registry';
|
||||||
import { ContractViewModel } from './contract-view-model.builder';
|
import {
|
||||||
|
ContractDynamicTemplateView,
|
||||||
|
ContractViewModel,
|
||||||
|
} from './contract-view-model.builder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signature row for the contract PDF. Mirrors the booking builder's
|
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||||
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||||||
let template = getTemplateMeta(templateKey);
|
let template = getTemplateMeta(templateKey);
|
||||||
|
|
||||||
// Prefer the admin-editable DB template matching the contract's
|
// The document articles come, in order of preference, from:
|
||||||
// direction/freight pair; fall back to the code-defined generic layout
|
// 1. this contract's frozen snapshot (staff accepted / edited it) — the
|
||||||
// when none is active.
|
// shared six templates are never consulted for these contracts;
|
||||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
// 2. the admin-editable DB template matching the direction/freight pair;
|
||||||
contract.tradeDirection,
|
// 3. the code-defined generic layout (handled below when none of the above).
|
||||||
contract.freightType,
|
const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
|
||||||
);
|
let dynamicTemplate: ContractDynamicTemplateView | undefined;
|
||||||
const dynamicTemplate = dynamicSource
|
if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
|
||||||
? {
|
dynamicTemplate = {
|
||||||
code: dynamicSource.code,
|
code: snapshot.code ?? 'CONTRACT',
|
||||||
name: dynamicSource.name,
|
name: snapshot.name ?? template.title,
|
||||||
documentTitle: dynamicSource.documentTitle,
|
documentTitle: snapshot.documentTitle ?? '',
|
||||||
whereasClauses: dynamicSource.whereasClauses ?? [],
|
whereasClauses: snapshot.whereasClauses ?? [],
|
||||||
articles: dynamicSource.articles ?? [],
|
articles: snapshot.articles,
|
||||||
}
|
};
|
||||||
: undefined;
|
} else {
|
||||||
|
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
);
|
||||||
|
dynamicTemplate = dynamicSource
|
||||||
|
? {
|
||||||
|
code: dynamicSource.code,
|
||||||
|
name: dynamicSource.name,
|
||||||
|
documentTitle: dynamicSource.documentTitle,
|
||||||
|
whereasClauses: dynamicSource.whereasClauses ?? [],
|
||||||
|
articles: dynamicSource.articles ?? [],
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
if (dynamicTemplate) {
|
if (dynamicTemplate) {
|
||||||
template = {
|
template = {
|
||||||
...template,
|
...template,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule-scoped wagon pins.
|
||||||
|
*
|
||||||
|
* Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
|
||||||
|
* (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
|
||||||
|
* Wagon entity, so the same physical wagon can serve many schedules (the July 17
|
||||||
|
* and July 20 runs of one train both use its 50 wagons). The Wagon columns
|
||||||
|
* `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
|
||||||
|
* meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
|
||||||
|
* cleared at arrive/unload/cancel).
|
||||||
|
*
|
||||||
|
* This migration erases the legacy pin-time stamps left by the old flow: any
|
||||||
|
* wagon pointing at a schedule that is not currently DISPATCHED (or that no
|
||||||
|
* longer exists) gets its pointers cleared, and — when the old flow had parked
|
||||||
|
* it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
|
||||||
|
* while coupled to a built train, otherwise AVAILABLE).
|
||||||
|
*/
|
||||||
|
export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
|
||||||
|
name = "ScheduleScopedWagonPins2210000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagons w
|
||||||
|
SET current_train_schedule_id = NULL,
|
||||||
|
train_set_wagon_id = NULL,
|
||||||
|
status = CASE
|
||||||
|
WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
|
||||||
|
ELSE w.status
|
||||||
|
END
|
||||||
|
WHERE w.deleted_at IS NULL
|
||||||
|
AND w.current_train_schedule_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
WHERE ts.id = w.current_train_schedule_id
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND ts.status = 'DISPATCHED'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Pin-time stamps cannot be reconstructed (the data was the bug); the
|
||||||
|
// slots on train_set_wagons still hold every live pin, so down is a no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
|
||||||
|
* contract-document template (articles + WHEREAS recitals) captured at staff
|
||||||
|
* accept. Staff can edit these articles for a single contract before generating
|
||||||
|
* its PDF; the edit never touches the shared six freight.contract_templates
|
||||||
|
* rows. Null on existing contracts → the PDF keeps rendering from the live
|
||||||
|
* template, so this is backward compatible.
|
||||||
|
*/
|
||||||
|
export class AddContractDocumentSnapshot2220000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
DROP COLUMN IF EXISTS document_snapshot;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
|
||||||
|
* The column is a plain varchar, so this is a data-only rename. Vehicles keep
|
||||||
|
* their own RETIRED status — only freight.wagons rows are touched.
|
||||||
|
*/
|
||||||
|
export class RenameWagonStatusRetiredToDetained2230000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'RenameWagonStatusRetiredToDetained2230000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every new wagon-transfer request must state WHY the wagons are needed; the
|
||||||
|
* reason is shown on the OCC request queue. Nullable in the DB — legacy rows
|
||||||
|
* predate the requirement; the DTO enforces it for new requests.
|
||||||
|
*/
|
||||||
|
export class AddTransferRequestReason2240000000000 implements MigrationInterface {
|
||||||
|
name = 'AddTransferRequestReason2240000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_transfer_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS reason text NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_transfer_requests
|
||||||
|
DROP COLUMN IF EXISTS reason
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval workflow for priority-rule changes: every create/update/delete of a
|
||||||
|
* priority config is filed here as a PENDING change request; an approver
|
||||||
|
* applies or rejects it. `payload` carries the proposed field values (null for
|
||||||
|
* DELETE), `priority_config_id` the target row (null for CREATE).
|
||||||
|
*/
|
||||||
|
export class CreatePriorityRuleChangeRequests2250000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreatePriorityRuleChangeRequests2250000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
action varchar(10) NOT NULL,
|
||||||
|
priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
|
||||||
|
payload jsonb NULL,
|
||||||
|
status varchar(10) NOT NULL DEFAULT 'PENDING',
|
||||||
|
requested_by_user_id uuid NULL,
|
||||||
|
decided_by_user_id uuid NULL,
|
||||||
|
decided_at timestamptz NULL,
|
||||||
|
decision_note text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prcr_status
|
||||||
|
ON freight.priority_rule_change_requests (status)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The binding shipment day must have at least one OPEN departure on the
|
// The binding shipment day must have at least one OPEN departure on the
|
||||||
// route — only schedule-backed days are selectable. The batch engine
|
// route — only schedule-backed days are selectable — AND some departure
|
||||||
// assigns the specific train within that (route, day) pool later.
|
// that day must be able to physically carry this cargo type (wagon-TYPE
|
||||||
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
// gate; quantity never blocks — oversized bookings get a partial split
|
||||||
booking.originYardId,
|
// offer). The batch engine assigns the specific train within that
|
||||||
booking.destinationYardId,
|
// (route, day) pool later.
|
||||||
eatDay(date),
|
const { hasDeparture, hasCompatible } =
|
||||||
);
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||||
|
booking,
|
||||||
|
eatDay(date),
|
||||||
|
);
|
||||||
if (!hasDeparture) {
|
if (!hasDeparture) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"No departures available on the selected day for this route",
|
"No departures available on the selected day for this route",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!hasCompatible) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
|
|||||||
@@ -348,6 +348,28 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/available-days')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||||||
|
})
|
||||||
|
async availableDays(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||||
|
) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||||
|
user?.id,
|
||||||
|
booking,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.bookingsService.availableDaysForBooking(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/mile-summary')
|
@Get(':id/mile-summary')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||||
|
|||||||
@@ -653,22 +653,37 @@ export class BookingsService {
|
|||||||
} else if (dto.scheduledDate) {
|
} else if (dto.scheduledDate) {
|
||||||
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||||
// directly). Require that the route has at least one OPEN departure on
|
// directly). Require that the route has at least one OPEN departure on
|
||||||
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
// that EAT day AND that some departure that day can physically carry the
|
||||||
// — it captures a non-binding estimatedShipmentDate instead, and the
|
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
|
||||||
// binding day is chosen later at the operation-request step. General
|
// a partial split offer later). The booking wizard does NOT send
|
||||||
// contracts also skip this (each drawdown order validates its own day).
|
// scheduledDate at creation — it captures a non-binding
|
||||||
|
// estimatedShipmentDate instead, and the binding day is chosen later at
|
||||||
|
// the operation-request step. General contracts also skip this (each
|
||||||
|
// drawdown order validates its own day).
|
||||||
const day = eatDay(new Date(dto.scheduledDate));
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
const hasDeparture =
|
const { hasDeparture, hasCompatible } =
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||||
dto.originYardId,
|
dto.originYardId,
|
||||||
dto.destinationYardId,
|
dto.destinationYardId,
|
||||||
day,
|
day,
|
||||||
|
{
|
||||||
|
freightType: dto.freightType as 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeId: dto.cargoTypeId,
|
||||||
|
containerTypeIds: (dto.containers ?? [])
|
||||||
|
.map((c) => c.containerTypeId)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!hasDeparture) {
|
if (!hasDeparture) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No departures available on the selected day for this route',
|
'No departures available on the selected day for this route',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!hasCompatible) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const containers = dto.containers ?? [];
|
const containers = dto.containers ?? [];
|
||||||
@@ -1149,6 +1164,52 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
|
||||||
|
private cargoIdentityOf(booking: Booking): {
|
||||||
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeId: booking.cargoTypeId ?? null,
|
||||||
|
containerTypeIds: (booking.bookingContainers ?? [])
|
||||||
|
.map((line) => line.containerTypeId)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day gate for a specific booking: OPEN departure exists AND some departure
|
||||||
|
* that day can physically carry the booking's cargo/container type.
|
||||||
|
* Quantity never blocks — oversized bookings get a partial split offer.
|
||||||
|
*/
|
||||||
|
async checkDayCompatibilityForBooking(
|
||||||
|
booking: Booking,
|
||||||
|
day: string,
|
||||||
|
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||||
|
return this.trainSchedulingService.checkDayCargoCompatibility(
|
||||||
|
booking.originYardId,
|
||||||
|
booking.destinationYardId,
|
||||||
|
day,
|
||||||
|
this.cargoIdentityOf(booking),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days the customer may pick for THIS booking (operation-request step):
|
||||||
|
* cargo-aware — only days whose departures can carry the booking's cargo
|
||||||
|
* type. Returns days only, no capacity counts.
|
||||||
|
*/
|
||||||
|
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
|
||||||
|
const booking = await this.findById(bookingId);
|
||||||
|
return this.trainSchedulingService.getAvailableDaysForCargo({
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
...this.cargoIdentityOf(booking),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batched version of the findById flag: marks each page item whose booking
|
* Batched version of the findById flag: marks each page item whose booking
|
||||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||||
import { Contract } from './entities/contract.entity';
|
import {
|
||||||
|
Contract,
|
||||||
|
ContractDocumentArticle,
|
||||||
|
ContractDocumentSnapshot,
|
||||||
|
ContractDocumentSnapshotInput,
|
||||||
|
} from './entities/contract.entity';
|
||||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
|
||||||
|
/** The editable contract-document draft returned for the accept/edit dialog. */
|
||||||
|
export interface ContractDocumentDraft {
|
||||||
|
documentTitle: string | null;
|
||||||
|
whereasClauses: string[];
|
||||||
|
articles: ContractDocumentArticle[];
|
||||||
|
code: string | null;
|
||||||
|
name: string | null;
|
||||||
|
/** True once the document may no longer be edited/regenerated. */
|
||||||
|
locked: boolean;
|
||||||
|
generatedAt: Date | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dropdown-settings code holding the admin-configured contract validity options
|
* Dropdown-settings code holding the admin-configured contract validity options
|
||||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||||
@@ -68,6 +88,7 @@ export class ContractTransitionService {
|
|||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -110,6 +131,7 @@ export class ContractTransitionService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
validityDays: number,
|
validityDays: number,
|
||||||
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
assertContractStatus(contract, ['SUBMITTED']);
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
@@ -128,6 +150,12 @@ export class ContractTransitionService {
|
|||||||
|
|
||||||
await this.instantiateApprovalSteps(contract);
|
await this.instantiateApprovalSteps(contract);
|
||||||
|
|
||||||
|
// Freeze the contract document for THIS contract only. Staff may have edited
|
||||||
|
// the articles in the accept dialog; otherwise the live template is captured
|
||||||
|
// as-is so later template edits never change an in-flight contract. The
|
||||||
|
// shared six templates are never written here.
|
||||||
|
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'PENDING_APPROVAL',
|
status: 'PENDING_APPROVAL',
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
@@ -135,12 +163,148 @@ export class ContractTransitionService {
|
|||||||
contractValidityDays: validityDays,
|
contractValidityDays: validityDays,
|
||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
|
documentSnapshot: snapshot,
|
||||||
} as never);
|
} as never);
|
||||||
const updated = await this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
this.notifier.accepted(updated);
|
this.notifier.accepted(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
||||||
|
* if one exists, else the live active template resolved for this contract's
|
||||||
|
* direction/freight pair. `locked` flips true once the document may no longer
|
||||||
|
* be edited (an approver has acted, or the contract has left the pre-approval
|
||||||
|
* window).
|
||||||
|
*/
|
||||||
|
async getContractDocumentDraft(
|
||||||
|
contractId: string,
|
||||||
|
): Promise<ContractDocumentDraft> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
const snapshot =
|
||||||
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||||
|
(await this.resolveDocumentSnapshot(contract));
|
||||||
|
return {
|
||||||
|
documentTitle: snapshot?.documentTitle ?? null,
|
||||||
|
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||||
|
articles: snapshot?.articles ?? [],
|
||||||
|
code: snapshot?.code ?? null,
|
||||||
|
name: snapshot?.name ?? null,
|
||||||
|
locked: !this.documentIsEditable(contract),
|
||||||
|
generatedAt: contract.contractGeneratedAt ?? null,
|
||||||
|
status: contract.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace this contract's document articles from the editor. Per-contract
|
||||||
|
* only — it writes the contract's own snapshot and never the shared templates.
|
||||||
|
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
||||||
|
* has acted).
|
||||||
|
*/
|
||||||
|
async updateContractDocument(
|
||||||
|
contractId: string,
|
||||||
|
input: ContractDocumentSnapshotInput,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||||
|
this.assertDocumentEditable(contract);
|
||||||
|
|
||||||
|
const current =
|
||||||
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||||
|
(await this.resolveDocumentSnapshot(contract));
|
||||||
|
const merged: ContractDocumentSnapshotInput = {
|
||||||
|
code: current?.code ?? null,
|
||||||
|
name: input.name ?? current?.name ?? null,
|
||||||
|
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
||||||
|
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||||
|
articles: input.articles ?? current?.articles ?? [],
|
||||||
|
};
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
documentSnapshot: this.normalizeSnapshot(merged),
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
||||||
|
* from the dialog; otherwise freeze the active template matching the
|
||||||
|
* contract's direction/freight. Returns null when no active template exists
|
||||||
|
* (the renderer then falls back to the built-in generic layout at render time).
|
||||||
|
*/
|
||||||
|
private async resolveDocumentSnapshot(
|
||||||
|
contract: Contract,
|
||||||
|
provided?: ContractDocumentSnapshotInput | null,
|
||||||
|
): Promise<ContractDocumentSnapshot | null> {
|
||||||
|
if (provided && (provided.articles?.length ?? 0) > 0) {
|
||||||
|
return this.normalizeSnapshot(provided);
|
||||||
|
}
|
||||||
|
const active = await this.contractTemplates.findActiveForContract(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
);
|
||||||
|
if (!active) return null;
|
||||||
|
return {
|
||||||
|
code: active.code,
|
||||||
|
name: active.name,
|
||||||
|
documentTitle: active.documentTitle,
|
||||||
|
whereasClauses: active.whereasClauses ?? [],
|
||||||
|
articles: this.normalizeArticles(active.articles ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(
|
||||||
|
input: ContractDocumentSnapshotInput,
|
||||||
|
): ContractDocumentSnapshot {
|
||||||
|
return {
|
||||||
|
code: input.code ?? null,
|
||||||
|
name: input.name ?? null,
|
||||||
|
documentTitle: input.documentTitle ?? null,
|
||||||
|
whereasClauses: Array.isArray(input.whereasClauses)
|
||||||
|
? input.whereasClauses
|
||||||
|
.map((c) => String(c))
|
||||||
|
.filter((c) => c.trim().length > 0)
|
||||||
|
: [],
|
||||||
|
articles: this.normalizeArticles(input.articles ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
||||||
|
private normalizeArticles(
|
||||||
|
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
||||||
|
): ContractDocumentArticle[] {
|
||||||
|
return articles
|
||||||
|
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
||||||
|
.map((a, index) => ({
|
||||||
|
id: a.id ?? randomUUID(),
|
||||||
|
title: (a.title ?? '').trim(),
|
||||||
|
body: a.body ?? '',
|
||||||
|
order: index + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-contract document may be edited/regenerated while the contract is at
|
||||||
|
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
||||||
|
* yet. The first approval action freezes it.
|
||||||
|
*/
|
||||||
|
private documentIsEditable(contract: Contract): boolean {
|
||||||
|
if (contract.status === 'SUBMITTED') return true;
|
||||||
|
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||||
|
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDocumentEditable(contract: Contract): void {
|
||||||
|
if (!this.documentIsEditable(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The contract document is locked — an approver has already acted or the ' +
|
||||||
|
'contract has advanced. It can no longer be edited or regenerated.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||||
@@ -303,6 +467,15 @@ export class ContractTransitionService {
|
|||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
|
||||||
|
// Approvers review the generated contract document, so it must exist before
|
||||||
|
// the first approval can be recorded. Staff generate it (from the frozen,
|
||||||
|
// optionally-edited snapshot) at the accept stage.
|
||||||
|
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Generate the contract document before it can be approved.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||||
if (!step || step.status !== 'PENDING') {
|
if (!step || step.status !== 'PENDING') {
|
||||||
throw new BadRequestException('Approval step not found or already actioned');
|
throw new BadRequestException('Approval step not found or already actioned');
|
||||||
@@ -350,15 +523,14 @@ export class ContractTransitionService {
|
|||||||
const updated = await this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
this.notifier.approved(updated);
|
this.notifier.approved(updated);
|
||||||
// Final approval step also generates the contract document from the
|
// Every step approved → CONTRACT_READY. The document was already generated
|
||||||
// template matching the contract's direction/freight pair. Best-effort:
|
// (and reviewed) at the accept stage, so we reuse it rather than
|
||||||
// a rendering hiccup must not roll back the approval — the document can
|
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
||||||
// still be generated manually or lazily on view/download.
|
|
||||||
try {
|
try {
|
||||||
return await this.generateContract(contractId);
|
return await this.finalizeApprovedContract(contractId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
|
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,30 +538,66 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
* Staff (re)generate the contract PDF. Two stages:
|
||||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
||||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
||||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
* it is blocked once an approver has acted (the document is then locked).
|
||||||
|
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
||||||
|
* CONTRACT_READY.
|
||||||
|
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
||||||
|
* transition — the document re-renders lazily on view/download.
|
||||||
*/
|
*/
|
||||||
async generateContract(contractId: string): Promise<Contract> {
|
async generateContract(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
if (contract.status === 'PENDING_APPROVAL') {
|
||||||
|
this.assertDocumentEditable(contract);
|
||||||
|
await this.renderContractDocument(contract);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
await this.renderContractDocument(contract);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
/**
|
||||||
|
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
||||||
|
* it via FilesService, and stamp the template key + generated timestamp. Never
|
||||||
|
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
||||||
|
* (it re-renders on view/download) but the timestamp is still stamped.
|
||||||
|
*/
|
||||||
|
private async renderContractDocument(contract: Contract): Promise<void> {
|
||||||
|
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||||
try {
|
try {
|
||||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.contractsRepository.update(contract.id, {
|
||||||
await this.contractsRepository.update(contractId, {
|
|
||||||
status: 'CONTRACT_READY',
|
|
||||||
contractTemplateKey: view.templateKey,
|
contractTemplateKey: view.templateKey,
|
||||||
contractGeneratedAt: new Date(),
|
contractGeneratedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every approval step landed → CONTRACT_READY. The document was already
|
||||||
|
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
||||||
|
* if it was somehow never generated. Never re-renders over an existing file.
|
||||||
|
*/
|
||||||
|
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (!contract.contractGeneratedAt) {
|
||||||
|
await this.renderContractDocument(contract);
|
||||||
|
}
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
|
|||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||||
|
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||||
import {
|
import {
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
RejectContractDto,
|
RejectContractDto,
|
||||||
@@ -340,9 +342,33 @@ export class ContractsController {
|
|||||||
id,
|
id,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.validityDays,
|
dto.validityDays,
|
||||||
|
dto.documentSnapshot,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/document/draft')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||||
|
})
|
||||||
|
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.transitionService.getContractDocumentDraft(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/document/articles')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||||
|
})
|
||||||
|
updateContractDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateContractDocumentDto,
|
||||||
|
) {
|
||||||
|
return this.transitionService.updateContractDocument(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/staff/request-changes')
|
@Post(':id/staff/request-changes')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, Max, Min } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||||
|
|
||||||
export class AcceptContractDto {
|
export class AcceptContractDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -14,4 +17,16 @@ export class AcceptContractDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(3650)
|
@Max(3650)
|
||||||
validityDays!: number;
|
validityDays!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-contract document override edited by staff in the accept
|
||||||
|
* dialog. When present its articles are frozen onto THIS contract; when
|
||||||
|
* omitted the live template is snapshotted as-is. Never edits the shared
|
||||||
|
* six templates.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ type: UpdateContractDocumentDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdateContractDocumentDto)
|
||||||
|
documentSnapshot?: UpdateContractDocumentDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** One article of a per-contract document override sent from the editor. */
|
||||||
|
export class ContractDocumentArticleDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
|
||||||
|
@IsString()
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-contract document override sent from the accept/edit editor. It edits
|
||||||
|
* ONLY this contract's frozen snapshot — it is never written back to the shared
|
||||||
|
* six {@link ContractTemplate} rows.
|
||||||
|
*/
|
||||||
|
export class UpdateContractDocumentDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
documentTitle?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
whereasClauses?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ContractDocumentArticleDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContractDocumentArticleDto)
|
||||||
|
articles!: ContractDocumentArticleDto[];
|
||||||
|
}
|
||||||
@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
|
|||||||
|
|
||||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||||
|
|
||||||
|
/** One article on a per-contract document snapshot (mirrors the template shape). */
|
||||||
|
export interface ContractDocumentArticle {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-contract copy of the resolved contract-document template, frozen when
|
||||||
|
* staff accept the contract for approval. Staff may edit these articles for a
|
||||||
|
* single contract in the accept/edit dialog — editing NEVER writes back to the
|
||||||
|
* shared six {@link ContractTemplate} rows. The PDF is rendered from this
|
||||||
|
* snapshot when present; a null snapshot renders from the live template.
|
||||||
|
*/
|
||||||
|
export interface ContractDocumentSnapshot {
|
||||||
|
code?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
documentTitle?: string | null;
|
||||||
|
whereasClauses: string[];
|
||||||
|
articles: ContractDocumentArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loose inbound shape (article ids/order optional) — normalized before store. */
|
||||||
|
export interface ContractDocumentSnapshotInput {
|
||||||
|
code?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
documentTitle?: string | null;
|
||||||
|
whereasClauses?: string[];
|
||||||
|
articles?: Array<{
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
order?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||||
|
|
||||||
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||||
contractGeneratedAt?: Date | null;
|
contractGeneratedAt?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-contract frozen copy of the document template (articles + WHEREAS),
|
||||||
|
* captured at staff accept. Editing it affects only this contract, never the
|
||||||
|
* shared six templates. Null → the PDF renders from the live template.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
|
||||||
|
documentSnapshot?: ContractDocumentSnapshot | null;
|
||||||
|
|
||||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
|
import {
|
||||||
|
DecidePriorityRuleChangeDto,
|
||||||
|
SubmitPriorityRuleChangeDto,
|
||||||
|
} from '../dto/priority-rule-change-request.dto';
|
||||||
|
import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
|
||||||
|
import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval workflow for priority-rule changes. Anyone with the manage
|
||||||
|
* permission SUBMITS a change; an approver (same permission — the team decides
|
||||||
|
* who reviews) approves or rejects it. The team is notified at each step.
|
||||||
|
*/
|
||||||
|
@ApiTags('priority-rule-change-requests')
|
||||||
|
@Controller('priority-rule-change-requests')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class PriorityRuleChangeRequestsController {
|
||||||
|
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RuleEngineManage('priority-configs')
|
||||||
|
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
|
||||||
|
submit(
|
||||||
|
@Body() dto: SubmitPriorityRuleChangeDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.submit(dto, user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RuleEngineView('priority-configs')
|
||||||
|
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
|
||||||
|
@ApiOperation({ summary: 'List priority-rule change requests' })
|
||||||
|
list(@Query('status') status?: PriorityRuleChangeStatus) {
|
||||||
|
return this.service.list(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@RuleEngineManage('priority-configs')
|
||||||
|
@ApiOperation({ summary: 'Approve and apply a pending change' })
|
||||||
|
approve(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: DecidePriorityRuleChangeDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.approve(id, user?.id, dto.decisionNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@RuleEngineManage('priority-configs')
|
||||||
|
@ApiOperation({ summary: 'Reject a pending change' })
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: DecidePriorityRuleChangeDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.reject(id, user?.id, dto.decisionNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CreatePriorityConfigDto } from './create-priority-config.dto';
|
||||||
|
import { UpdatePriorityConfigDto } from './update-priority-config.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a priority-rule change for approval. CREATE carries a full `create`
|
||||||
|
* payload; UPDATE carries the target id + an `update` patch; DELETE carries
|
||||||
|
* only the target id.
|
||||||
|
*/
|
||||||
|
export class SubmitPriorityRuleChangeDto {
|
||||||
|
@ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
|
||||||
|
@IsIn(['CREATE', 'UPDATE', 'DELETE'])
|
||||||
|
action!: 'CREATE' | 'UPDATE' | 'DELETE';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
priorityConfigId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CreatePriorityConfigDto)
|
||||||
|
create?: CreatePriorityConfigDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdatePriorityConfigDto)
|
||||||
|
update?: UpdatePriorityConfigDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DecidePriorityRuleChangeDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
decisionNote?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { PriorityConfig } from './priority-config.entity';
|
||||||
|
|
||||||
|
export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
|
||||||
|
export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One proposed change to a priority rule, awaiting approval. Every
|
||||||
|
* create/update/delete of a priority config is filed here first; an approver
|
||||||
|
* applies (which runs the real mutation, including range-collision checks) or
|
||||||
|
* rejects it. `payload` holds the proposed field values (null for DELETE);
|
||||||
|
* `priorityConfigId` the target rule (null for CREATE).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
|
||||||
|
@Index(['status'])
|
||||||
|
export class PriorityRuleChangeRequest extends BaseEntity {
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||||
|
action!: PriorityRuleChangeAction;
|
||||||
|
|
||||||
|
@Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
|
||||||
|
priorityConfigId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => PriorityConfig, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'priority_config_id' })
|
||||||
|
priorityConfig?: PriorityConfig | null;
|
||||||
|
|
||||||
|
@Column({ name: 'payload', type: 'jsonb', nullable: true })
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
|
||||||
|
status!: PriorityRuleChangeStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
requestedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
decidedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
|
||||||
|
decidedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'decision_note', type: 'text', nullable: true })
|
||||||
|
decisionNote?: string | null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
|
|||||||
import { CargoTypesController } from './controllers/cargo-types.controller';
|
import { CargoTypesController } from './controllers/cargo-types.controller';
|
||||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||||
|
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
|
||||||
import { RatesController } from './controllers/rates.controller';
|
import { RatesController } from './controllers/rates.controller';
|
||||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||||
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
|
|||||||
import { CargoType } from './entities/cargo-type.entity';
|
import { CargoType } from './entities/cargo-type.entity';
|
||||||
import { ContainerType } from './entities/container-type.entity';
|
import { ContainerType } from './entities/container-type.entity';
|
||||||
import { PriorityConfig } from './entities/priority-config.entity';
|
import { PriorityConfig } from './entities/priority-config.entity';
|
||||||
|
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
|
||||||
import { Rate } from './entities/rate.entity';
|
import { Rate } from './entities/rate.entity';
|
||||||
import { ServiceType } from './entities/service-type.entity';
|
import { ServiceType } from './entities/service-type.entity';
|
||||||
import { ShippingLine } from './entities/shipping-line.entity';
|
import { ShippingLine } from './entities/shipping-line.entity';
|
||||||
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
|
|||||||
import { CargoTypesService } from './services/cargo-types.service';
|
import { CargoTypesService } from './services/cargo-types.service';
|
||||||
import { ContainerTypesService } from './services/container-types.service';
|
import { ContainerTypesService } from './services/container-types.service';
|
||||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||||
|
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
|
||||||
import { RatesService } from './services/rates.service';
|
import { RatesService } from './services/rates.service';
|
||||||
import { ServiceTypesService } from './services/service-types.service';
|
import { ServiceTypesService } from './services/service-types.service';
|
||||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||||
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
|
|||||||
|
|
||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
|
||||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoType,
|
CargoType,
|
||||||
ContainerType,
|
ContainerType,
|
||||||
PriorityConfig,
|
PriorityConfig,
|
||||||
|
PriorityRuleChangeRequest,
|
||||||
ServiceType,
|
ServiceType,
|
||||||
WeightLimitRule,
|
WeightLimitRule,
|
||||||
Yard,
|
Yard,
|
||||||
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
]),
|
]),
|
||||||
|
// Team notifications for the priority-rule approval workflow.
|
||||||
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
CargoTypesController,
|
CargoTypesController,
|
||||||
ContainerTypesController,
|
ContainerTypesController,
|
||||||
PriorityConfigsController,
|
PriorityConfigsController,
|
||||||
|
PriorityRuleChangeRequestsController,
|
||||||
ServiceTypesController,
|
ServiceTypesController,
|
||||||
WeightLimitRulesController,
|
WeightLimitRulesController,
|
||||||
YardsController,
|
YardsController,
|
||||||
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoTypesService,
|
CargoTypesService,
|
||||||
ContainerTypesService,
|
ContainerTypesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
|
PriorityRuleChangeRequestsService,
|
||||||
ServiceTypesService,
|
ServiceTypesService,
|
||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export class PriorityConfigsService {
|
|||||||
|
|
||||||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||||||
this.validateCurrencyField(dto.type, dto.currency);
|
this.validateCurrencyField(dto.type, dto.currency);
|
||||||
|
await this.assertNoRangeCollision({
|
||||||
|
type: dto.type,
|
||||||
|
currency: dto.currency ?? null,
|
||||||
|
minWagonCount: dto.minWagonCount,
|
||||||
|
maxWagonCount: dto.maxWagonCount,
|
||||||
|
});
|
||||||
|
|
||||||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||||||
|
|
||||||
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
|
|||||||
const type = dto.type ?? existing.type;
|
const type = dto.type ?? existing.type;
|
||||||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||||||
this.validateCurrencyField(type, currency);
|
this.validateCurrencyField(type, currency);
|
||||||
|
await this.assertNoRangeCollision({
|
||||||
|
type,
|
||||||
|
currency: currency ?? null,
|
||||||
|
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
|
||||||
|
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
|
||||||
|
excludeId: id,
|
||||||
|
});
|
||||||
|
|
||||||
const { ...patch } = dto;
|
const { ...patch } = dto;
|
||||||
const updated = await this.repository.update(id, patch);
|
const updated = await this.repository.update(id, patch);
|
||||||
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No two rules of the same type (and, for CURRENCY rules, the same currency)
|
||||||
|
* may cover overlapping wagon-count ranges — a booking must match at most one
|
||||||
|
* rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
|
||||||
|
* overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
|
||||||
|
*/
|
||||||
|
async assertNoRangeCollision(input: {
|
||||||
|
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||||
|
currency?: string | null;
|
||||||
|
minWagonCount: number;
|
||||||
|
maxWagonCount: number;
|
||||||
|
excludeId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (input.minWagonCount > input.maxWagonCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Min wagon count cannot be greater than max wagon count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const siblings = await this.repository.findAll({
|
||||||
|
where: { type: input.type },
|
||||||
|
});
|
||||||
|
const clash = siblings.find(
|
||||||
|
(s) =>
|
||||||
|
s.id !== input.excludeId &&
|
||||||
|
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
|
||||||
|
input.minWagonCount <= s.maxWagonCount &&
|
||||||
|
input.maxWagonCount >= s.minWagonCount,
|
||||||
|
);
|
||||||
|
if (clash) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
|
||||||
|
`"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
|
||||||
|
'Adjust the range so rules do not collide.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
} from '@edr/types';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
|
||||||
|
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||||
|
import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
|
||||||
|
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||||
|
import {
|
||||||
|
PriorityRuleChangeRequest,
|
||||||
|
PriorityRuleChangeStatus,
|
||||||
|
} from '../entities/priority-rule-change-request.entity';
|
||||||
|
import { PriorityConfigsService } from './priority-configs.service';
|
||||||
|
|
||||||
|
/** Backoffice rule-engine page — where both queue and rules live. */
|
||||||
|
const RULES_LINK = '/dashboard/rules/priority-configs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval workflow for priority-rule changes. Nobody mutates priority configs
|
||||||
|
* directly any more: a change is SUBMITTED here (validated up front so the
|
||||||
|
* requester gets immediate feedback on range collisions), the team is
|
||||||
|
* notified, and an approver later applies or rejects it. Applying re-runs the
|
||||||
|
* full validation — the winning state is whatever is true at approval time.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PriorityRuleChangeRequestsService {
|
||||||
|
private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PriorityRuleChangeRequest)
|
||||||
|
private readonly repo: Repository<PriorityRuleChangeRequest>,
|
||||||
|
private readonly configs: PriorityConfigsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async submit(
|
||||||
|
dto: SubmitPriorityRuleChangeDto,
|
||||||
|
userId?: string | null,
|
||||||
|
): Promise<PriorityRuleChangeRequest> {
|
||||||
|
const payload = await this.validateSubmission(dto);
|
||||||
|
|
||||||
|
const request = await this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
action: dto.action,
|
||||||
|
priorityConfigId: dto.priorityConfigId ?? null,
|
||||||
|
payload,
|
||||||
|
status: 'PENDING',
|
||||||
|
requestedByUserId: userId ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.notifyTeam(
|
||||||
|
'Priority rule change submitted',
|
||||||
|
`A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(status?: PriorityRuleChangeStatus): Promise<PriorityRuleChangeRequest[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: status ? { status } : {},
|
||||||
|
relations: { priorityConfig: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(
|
||||||
|
id: string,
|
||||||
|
userId?: string | null,
|
||||||
|
decisionNote?: string,
|
||||||
|
): Promise<PriorityRuleChangeRequest> {
|
||||||
|
const request = await this.findPending(id);
|
||||||
|
|
||||||
|
// Apply the change through the normal service so currency + range-collision
|
||||||
|
// validation runs against the CURRENT rules; a stale request that now
|
||||||
|
// collides fails here and stays PENDING for the approver to see the error.
|
||||||
|
if (request.action === 'CREATE') {
|
||||||
|
await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
|
||||||
|
} else if (request.action === 'UPDATE') {
|
||||||
|
await this.configs.update(
|
||||||
|
this.requireTarget(request),
|
||||||
|
request.payload as unknown as UpdatePriorityConfigDto,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.configs.remove(this.requireTarget(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
request.status = 'APPROVED';
|
||||||
|
request.decidedByUserId = userId ?? null;
|
||||||
|
request.decidedAt = new Date();
|
||||||
|
request.decisionNote = decisionNote ?? null;
|
||||||
|
const saved = await this.repo.save(request);
|
||||||
|
|
||||||
|
this.notifyTeam(
|
||||||
|
'Priority rule change approved',
|
||||||
|
`The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
|
||||||
|
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
||||||
|
saved,
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(
|
||||||
|
id: string,
|
||||||
|
userId?: string | null,
|
||||||
|
decisionNote?: string,
|
||||||
|
): Promise<PriorityRuleChangeRequest> {
|
||||||
|
const request = await this.findPending(id);
|
||||||
|
request.status = 'REJECTED';
|
||||||
|
request.decidedByUserId = userId ?? null;
|
||||||
|
request.decidedAt = new Date();
|
||||||
|
request.decisionNote = decisionNote ?? null;
|
||||||
|
const saved = await this.repo.save(request);
|
||||||
|
|
||||||
|
this.notifyTeam(
|
||||||
|
'Priority rule change rejected',
|
||||||
|
`The ${request.action.toLowerCase()} priority-rule change was rejected.` +
|
||||||
|
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
||||||
|
saved,
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a submission the way applying it would, so bad requests are
|
||||||
|
* refused at the door — most importantly the wagon-range collision rule.
|
||||||
|
* Returns the payload to persist.
|
||||||
|
*/
|
||||||
|
private async validateSubmission(
|
||||||
|
dto: SubmitPriorityRuleChangeDto,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
if (dto.action === 'CREATE') {
|
||||||
|
if (!dto.create) {
|
||||||
|
throw new BadRequestException('CREATE requires the proposed rule in `create`');
|
||||||
|
}
|
||||||
|
await this.configs.assertNoRangeCollision({
|
||||||
|
type: dto.create.type,
|
||||||
|
currency: dto.create.currency ?? null,
|
||||||
|
minWagonCount: dto.create.minWagonCount,
|
||||||
|
maxWagonCount: dto.create.maxWagonCount,
|
||||||
|
});
|
||||||
|
return { ...dto.create };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.priorityConfigId) {
|
||||||
|
throw new BadRequestException(`${dto.action} requires priorityConfigId`);
|
||||||
|
}
|
||||||
|
const existing = await this.configs.findById(dto.priorityConfigId);
|
||||||
|
|
||||||
|
if (dto.action === 'DELETE') return null;
|
||||||
|
|
||||||
|
if (!dto.update || Object.keys(dto.update).length === 0) {
|
||||||
|
throw new BadRequestException('UPDATE requires the field changes in `update`');
|
||||||
|
}
|
||||||
|
await this.configs.assertNoRangeCollision({
|
||||||
|
type: dto.update.type ?? existing.type,
|
||||||
|
currency:
|
||||||
|
dto.update.currency !== undefined ? dto.update.currency : existing.currency,
|
||||||
|
minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
|
||||||
|
maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
|
||||||
|
excludeId: existing.id,
|
||||||
|
});
|
||||||
|
return { ...dto.update };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPending(id: string): Promise<PriorityRuleChangeRequest> {
|
||||||
|
const request = await this.repo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { priorityConfig: true },
|
||||||
|
});
|
||||||
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
|
if (request.status !== 'PENDING') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Change request is already ${request.status.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireTarget(request: PriorityRuleChangeRequest): string {
|
||||||
|
if (!request.priorityConfigId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${request.action} change request has no target rule`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return request.priorityConfigId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app notification to the whole backoffice team (submission AND decision
|
||||||
|
* both notify the team; the requester is staff, so they are included).
|
||||||
|
* Fire-and-forget — a notification failure never blocks the workflow.
|
||||||
|
*/
|
||||||
|
private notifyTeam(
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
request: PriorityRuleChangeRequest,
|
||||||
|
): void {
|
||||||
|
void this.inbox
|
||||||
|
.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: RULES_LINK,
|
||||||
|
data: { priorityRuleChangeRequestId: request.id, action: request.action },
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(
|
||||||
|
`Priority-rule notification failed: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
cargoTypeCode?: string;
|
cargoTypeCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cargoTypeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value == null || value === '') return undefined;
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
|
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
|||||||
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
|
|||||||
originYardId: query.originYardId,
|
originYardId: query.originYardId,
|
||||||
destinationYardId: query.destinationYardId,
|
destinationYardId: query.destinationYardId,
|
||||||
freightType: query.freightType,
|
freightType: query.freightType,
|
||||||
|
cargoTypeId: query.cargoTypeId,
|
||||||
cargoTypeCode: query.cargoTypeCode,
|
cargoTypeCode: query.cargoTypeCode,
|
||||||
totalWeightTons: query.totalWeightTons,
|
totalWeightTons: query.totalWeightTons,
|
||||||
containers: query.containers,
|
containers: query.containers,
|
||||||
|
containerTypeIds: query.containerTypeIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
|
|||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const trainSetId = schedule.trainSetId;
|
const trainSetId = schedule.trainSetId;
|
||||||
|
|
||||||
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
|
|
||||||
|
|
||||||
const deletedAllocationIds =
|
const deletedAllocationIds =
|
||||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
||||||
|
|
||||||
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
|
|||||||
(sb) => sb.bookingId !== bookingId,
|
(sb) => sb.bookingId !== bookingId,
|
||||||
);
|
);
|
||||||
if (remainingBookings.length === 0) {
|
if (remainingBookings.length === 0) {
|
||||||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
|
||||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||||
schedule.trainSetId,
|
schedule.trainSetId,
|
||||||
manager,
|
manager,
|
||||||
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
||||||
}
|
}
|
||||||
|
|
||||||
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
|
const slots = schedule.trainSet?.wagons ?? [];
|
||||||
|
const slotIds = new Set(slots.map((w) => w.id));
|
||||||
|
const slotById = new Map(slots.map((w) => [w.id, w]));
|
||||||
|
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
|
||||||
|
// Occupancy is judged against THIS schedule's own slots only — a wagon
|
||||||
|
// pinned on another schedule (e.g. the same train's July 17 run) stays
|
||||||
|
// pinnable here.
|
||||||
|
const slotIdByPhysicalId = new Map(
|
||||||
|
slots
|
||||||
|
.filter((w) => w.physicalWagonId)
|
||||||
|
.map((w) => [w.physicalWagonId as string, w.id]),
|
||||||
|
);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
for (const assignment of dto.assignments) {
|
for (const assignment of dto.assignments) {
|
||||||
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
|
|||||||
if (!physicalWagon) {
|
if (!physicalWagon) {
|
||||||
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
||||||
}
|
}
|
||||||
if (
|
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
|
||||||
physicalWagon.status !== WagonStatus.Available &&
|
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
|
||||||
physicalWagon.currentTrainScheduleId !== scheduleId
|
const occupyingSlot = slotById.get(occupyingSlotId);
|
||||||
) {
|
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
if (builtTrainId) {
|
||||||
throw new ConflictException(
|
// Train-bound schedule: only the built train's own consist may be
|
||||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
// pinned — wherever the wagons currently sit, they travel with the
|
||||||
);
|
// train, so no yard/status gate applies.
|
||||||
|
if (physicalWagon.trainId !== builtTrainId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (physicalWagon.trainId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
physicalWagon.currentTrainScheduleId &&
|
||||||
|
physicalWagon.currentTrainScheduleId !== scheduleId
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||||||
|
// its status untouched so other schedules can still use the wagon.
|
||||||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||||||
physicalWagonId: assignment.physicalWagonId,
|
physicalWagonId: assignment.physicalWagonId,
|
||||||
status: 'RESERVED',
|
status: 'RESERVED',
|
||||||
});
|
});
|
||||||
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
|
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||||||
trainSetWagonId: assignment.trainSetWagonId,
|
if (slotId === assignment.trainSetWagonId) {
|
||||||
currentTrainScheduleId: scheduleId,
|
slotIdByPhysicalId.delete(physicalId);
|
||||||
status: WagonStatus.Assigned,
|
break;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
|
|||||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||||||
|
// Same rule for wagons: many schedules may pin the same wagon, but it can
|
||||||
|
// only be OUT on one dispatched train at a time.
|
||||||
|
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||||
|
.map((slot) => slot.physicalWagonId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (pinnedPhysicalIds.length) {
|
||||||
|
const rolling = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
|
||||||
|
});
|
||||||
|
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
|
||||||
|
if (busy.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
|
|||||||
originYardId: string,
|
originYardId: string,
|
||||||
targetScheduleId?: string,
|
targetScheduleId?: string,
|
||||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||||
const [wagons, wagonTypes, builtTrainId] = await Promise.all([
|
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
||||||
this.dataSource.getRepository(Wagon).find(),
|
this.dataSource.getRepository(Wagon).find(),
|
||||||
this.dataSource.getRepository(WagonType).find(),
|
this.dataSource.getRepository(WagonType).find(),
|
||||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||||
|
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||||
]);
|
]);
|
||||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||||
const counts = new Map<string, { code: string; available: number }>();
|
const counts = new Map<string, { code: string; available: number }>();
|
||||||
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
|
|||||||
if (builtTrainId) {
|
if (builtTrainId) {
|
||||||
if (wagon.trainId !== builtTrainId) continue;
|
if (wagon.trainId !== builtTrainId) continue;
|
||||||
} else {
|
} else {
|
||||||
const pinnedOnTarget = targetScheduleId
|
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||||
? wagon.currentTrainScheduleId === targetScheduleId
|
// consume a wagon here — the same physical wagon may serve the July 17
|
||||||
: false;
|
// and the July 20 run. A wagon is unusable only when it is coupled to a
|
||||||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
// built train's consist, physically blocked, or out on a dispatched
|
||||||
|
// train right now.
|
||||||
|
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
|
||||||
|
if (wagon.trainId) continue;
|
||||||
|
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
|
||||||
|
if (
|
||||||
|
wagon.currentTrainScheduleId &&
|
||||||
|
wagon.currentTrainScheduleId !== targetScheduleId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (wagon.currentYardId !== originYardId) continue;
|
if (wagon.currentYardId !== originYardId) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
/**
|
||||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
* A wagon in a blocked physical state can never be planned or pinned.
|
||||||
const physicalIds = slots
|
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
|
||||||
.map((slot) => slot.physicalWagonId)
|
* train or stamped by a live run — schedule-level occupancy is tracked on
|
||||||
.filter((id): id is string => Boolean(id));
|
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
|
||||||
if (!physicalIds.length) return;
|
*/
|
||||||
const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
|
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
|
||||||
for (const wagon of wagons) {
|
return (
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
|
||||||
// Built-train wagons stay coupled to their train (ASSIGNED); loose
|
);
|
||||||
// wagons return to the open AVAILABLE pool.
|
}
|
||||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
|
||||||
trainSetWagonId: null,
|
/**
|
||||||
currentTrainScheduleId: null,
|
* Physical wagons already pinned to THIS schedule's slots. Availability is
|
||||||
});
|
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
|
||||||
}
|
* pins held by other schedules of the same train are irrelevant.
|
||||||
|
*/
|
||||||
|
private async pinnedPhysicalWagonIdsForSchedule(
|
||||||
|
scheduleId: string | undefined,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (!scheduleId) return new Set();
|
||||||
|
const runner = manager ?? this.dataSource;
|
||||||
|
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||||
|
`SELECT tsw.physical_wagon_id
|
||||||
|
FROM freight.train_set_wagons tsw
|
||||||
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||||
|
WHERE ts.id = $1
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
|
||||||
|
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
||||||
|
* schedule-occupancy state anymore.
|
||||||
|
*/
|
||||||
|
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
|
||||||
|
const runner = manager ?? this.dataSource;
|
||||||
|
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||||
|
`SELECT DISTINCT tsw.physical_wagon_id
|
||||||
|
FROM freight.train_set_wagons tsw
|
||||||
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||||
|
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||||
|
);
|
||||||
|
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async autoPinWagonsForSchedule(
|
private async autoPinWagonsForSchedule(
|
||||||
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
|
|||||||
const wagons = await manager.getRepository(Wagon).find();
|
const wagons = await manager.getRepository(Wagon).find();
|
||||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||||||
|
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||||||
|
scheduleId,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||||||
|
|
||||||
const planSlots = [...slots]
|
const planSlots = [...slots]
|
||||||
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
originYardId,
|
originYardId,
|
||||||
builtTrainId,
|
builtTrainId,
|
||||||
|
pinnedToScheduleIds,
|
||||||
);
|
);
|
||||||
if (unpinnable.length) {
|
if (unpinnable.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
|
|||||||
originYardId,
|
originYardId,
|
||||||
assignedPhysicalIds,
|
assignedPhysicalIds,
|
||||||
builtTrainId,
|
builtTrainId,
|
||||||
|
pinnedToScheduleIds,
|
||||||
);
|
);
|
||||||
if (!physical) continue;
|
if (!physical) continue;
|
||||||
|
|
||||||
|
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
|
||||||
|
// touched here, so the same physical wagon stays free for every other
|
||||||
|
// schedule (it gets stamped at dispatch, when it physically leaves).
|
||||||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||||||
physicalWagonId: physical.id,
|
physicalWagonId: physical.id,
|
||||||
status: 'RESERVED',
|
status: 'RESERVED',
|
||||||
});
|
});
|
||||||
await manager.getRepository(Wagon).update(physical.id, {
|
|
||||||
trainSetWagonId: slot.trainSetWagonId,
|
|
||||||
currentTrainScheduleId: scheduleId,
|
|
||||||
status: WagonStatus.Assigned,
|
|
||||||
});
|
|
||||||
assignedPhysicalIds.add(physical.id);
|
assignedPhysicalIds.add(physical.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
|
|||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!wagonPlan.length) return [];
|
if (!wagonPlan.length) return [];
|
||||||
|
|
||||||
const [wagons, builtTrainId] = await Promise.all([
|
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||||
this.dataSource.getRepository(Wagon).find(),
|
this.dataSource.getRepository(Wagon).find(),
|
||||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||||
|
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||||
]);
|
]);
|
||||||
return this.findUnpinnableWagonSlots(
|
return this.findUnpinnableWagonSlots(
|
||||||
wagonPlan.map((slot) => ({
|
wagonPlan.map((slot) => ({
|
||||||
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
|
|||||||
targetScheduleId,
|
targetScheduleId,
|
||||||
originYardId,
|
originYardId,
|
||||||
builtTrainId,
|
builtTrainId,
|
||||||
|
pinnedToScheduleIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
|
|||||||
scheduleId: string | undefined,
|
scheduleId: string | undefined,
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
builtTrainId: string | null = null,
|
builtTrainId: string | null = null,
|
||||||
|
pinnedToScheduleIds: Set<string> = new Set(),
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const assignedPhysicalIds = new Set<string>();
|
const assignedPhysicalIds = new Set<string>();
|
||||||
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
|
|||||||
originYardId,
|
originYardId,
|
||||||
assignedPhysicalIds,
|
assignedPhysicalIds,
|
||||||
builtTrainId,
|
builtTrainId,
|
||||||
|
pinnedToScheduleIds,
|
||||||
);
|
);
|
||||||
if (!physical) {
|
if (!physical) {
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
|
|||||||
originYardId: string,
|
originYardId: string,
|
||||||
assignedPhysicalIds: Set<string>,
|
assignedPhysicalIds: Set<string>,
|
||||||
builtTrainId: string | null = null,
|
builtTrainId: string | null = null,
|
||||||
|
pinnedToScheduleIds: Set<string> = new Set(),
|
||||||
): Wagon | undefined {
|
): Wagon | undefined {
|
||||||
const usable = (wagon: Wagon): boolean => {
|
const usable = (wagon: Wagon): boolean => {
|
||||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||||
const pinnedOnSchedule = scheduleId
|
// Loose pool never lends a wagon coupled to a built train's consist.
|
||||||
? wagon.currentTrainScheduleId === scheduleId
|
if (wagon.trainId) return false;
|
||||||
: false;
|
// Out on a dispatched train right now — physically gone.
|
||||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
if (
|
||||||
|
wagon.currentTrainScheduleId &&
|
||||||
|
wagon.currentTrainScheduleId !== scheduleId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
|
||||||
|
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
|
||||||
};
|
};
|
||||||
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
|
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
|
||||||
// wherever they currently sit (they travel with the train), never a loose
|
// wherever they currently sit (they travel with the train), never a loose
|
||||||
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
|
|||||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||||
.map((slot) => slot.physicalWagonId as string),
|
.map((slot) => slot.physicalWagonId as string),
|
||||||
);
|
);
|
||||||
|
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
||||||
|
|
||||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||||
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
|
|||||||
wagons: wagons.map((wagon) => ({
|
wagons: wagons.map((wagon) => ({
|
||||||
...mapWagon(wagon),
|
...mapWagon(wagon),
|
||||||
loaded: loadedWagonIds.has(wagon.id),
|
loaded: loadedWagonIds.has(wagon.id),
|
||||||
// Free = not pinned to any run; only free wagons can be trimmed.
|
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
|
||||||
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
|
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
|
||||||
})),
|
})),
|
||||||
addableWagons: addableWagons.map(mapWagon),
|
addableWagons: addableWagons.map(mapWagon),
|
||||||
adjustments: adjustments.map((log) => ({
|
adjustments: adjustments.map((log) => ({
|
||||||
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
|
|||||||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||||
|
|
||||||
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
||||||
|
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
|
||||||
const removed: Wagon[] = [];
|
const removed: Wagon[] = [];
|
||||||
for (const wagonId of removeWagonIds) {
|
for (const wagonId of removeWagonIds) {
|
||||||
const wagon = consistById.get(wagonId);
|
const wagon = consistById.get(wagonId);
|
||||||
if (!wagon) {
|
if (!wagon) {
|
||||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||||
}
|
}
|
||||||
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
|
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
||||||
);
|
);
|
||||||
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
|
|||||||
/**
|
/**
|
||||||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||||
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
* train capacity (not fully allocated) AND its wagon stock can physically carry
|
||||||
* checked here: whether a matching wagon currently sits in the right yard is an
|
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
|
||||||
* operational question staff resolve when they approve or reject the booking,
|
* NOT gated — a booking bigger than the free capacity is accepted and the batch
|
||||||
* not something the customer can act on while choosing a date. Same
|
* engine offers a partial split later. No counts are exposed: same
|
||||||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||||
* not a train.
|
* not a train.
|
||||||
*/
|
*/
|
||||||
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
|
|||||||
originYardId?: string;
|
originYardId?: string;
|
||||||
destinationYardId?: string;
|
destinationYardId?: string;
|
||||||
freightType: 'CONTAINER' | 'BULK';
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
cargoTypeCode?: string | null;
|
cargoTypeCode?: string | null;
|
||||||
totalWeightTons?: number;
|
totalWeightTons?: number;
|
||||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||||
|
containerTypeIds?: string[];
|
||||||
}): Promise<{ days: string[] }> {
|
}): Promise<{ days: string[] }> {
|
||||||
const schedules = await this.getBookableScheduleEntities(
|
const schedules = await this.getBookableScheduleEntities(
|
||||||
input.originYardId,
|
input.originYardId,
|
||||||
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (schedules.length === 0) return { days: [] };
|
if (schedules.length === 0) return { days: [] };
|
||||||
|
|
||||||
|
const withCapacity = schedules.filter(
|
||||||
|
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
|
||||||
|
);
|
||||||
|
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||||||
|
|
||||||
const days = new Set<string>();
|
const days = new Set<string>();
|
||||||
for (const s of schedules) {
|
for (const s of compatible) {
|
||||||
const hasCapacity =
|
|
||||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
|
||||||
if (!hasCapacity) continue;
|
|
||||||
if (s.scheduledDepartureDate)
|
if (s.scheduledDepartureDate)
|
||||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||||
}
|
}
|
||||||
return { days: [...days].sort() };
|
return { days: [...days].sort() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
|
||||||
|
* whose wagon stock can physically carry the selected cargo — every container
|
||||||
|
* line (or the bulk cargo type) must map to at least one wagon type the
|
||||||
|
* schedule's stock actually has. Stock = the built train's own consist, or the
|
||||||
|
* origin yard's loose pool for schedules assembled from loose locomotives.
|
||||||
|
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
|
||||||
|
* a partial split offer from the batch engine later.
|
||||||
|
*/
|
||||||
|
private async filterCargoCompatibleSchedules(
|
||||||
|
schedules: TrainSchedule[],
|
||||||
|
cargo: {
|
||||||
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cargoTypeCode?: string | null;
|
||||||
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
},
|
||||||
|
): Promise<TrainSchedule[]> {
|
||||||
|
if (!schedules.length) return schedules;
|
||||||
|
const required = await this.requiredWagonTypeSets(cargo);
|
||||||
|
// No cargo identity supplied — nothing to gate on (legacy callers).
|
||||||
|
if (required === null) return schedules;
|
||||||
|
|
||||||
|
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
|
||||||
|
return schedules.filter((s) => {
|
||||||
|
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
|
||||||
|
return required.every((set) => {
|
||||||
|
for (const typeId of set) if (stock.has(typeId)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Set of allowed wagon-type ids per required cargo dimension: per
|
||||||
|
* container line's type (or per container size when only sizes are known),
|
||||||
|
* or a single set for the bulk cargo type. `null` = no cargo identity given,
|
||||||
|
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
|
||||||
|
* configured) — the gate then blocks every schedule, mirroring the hard
|
||||||
|
* config violation scheduling raises for the same state.
|
||||||
|
*/
|
||||||
|
private async requiredWagonTypeSets(cargo: {
|
||||||
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cargoTypeCode?: string | null;
|
||||||
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
}): Promise<Set<string>[] | null> {
|
||||||
|
if (cargo.freightType === 'CONTAINER') {
|
||||||
|
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
|
||||||
|
if (typeIds.length) {
|
||||||
|
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
|
||||||
|
FROM freight.container_types ct
|
||||||
|
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||||
|
LEFT JOIN freight.wagon_types wt
|
||||||
|
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||||
|
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
|
||||||
|
[typeIds],
|
||||||
|
);
|
||||||
|
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
|
||||||
|
}
|
||||||
|
return [...byType.values()];
|
||||||
|
}
|
||||||
|
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
|
||||||
|
// ANY active container type of that size has a matching wagon type.
|
||||||
|
const sizes = [
|
||||||
|
...new Set(
|
||||||
|
(cargo.containers ?? [])
|
||||||
|
.map((line) => parseInt(String(line.containerSize), 10))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!sizes.length) return null;
|
||||||
|
const rows: { size_ft: number; wagon_type_id: string | null }[] =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT ct.size_ft, wt.id AS wagon_type_id
|
||||||
|
FROM freight.container_types ct
|
||||||
|
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||||
|
LEFT JOIN freight.wagon_types wt
|
||||||
|
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||||
|
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
|
||||||
|
AND (ct.is_active IS DISTINCT FROM false)`,
|
||||||
|
[sizes],
|
||||||
|
);
|
||||||
|
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
|
||||||
|
}
|
||||||
|
return [...bySize.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
|
||||||
|
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
|
||||||
|
`SELECT wt.id AS wagon_type_id
|
||||||
|
FROM freight.cargo_types c
|
||||||
|
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
|
||||||
|
LEFT JOIN freight.wagon_types wt
|
||||||
|
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
|
||||||
|
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
|
||||||
|
);
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
|
||||||
|
return [set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon-type ids each schedule's stock can offer: the built train's own
|
||||||
|
* consist for train-bound schedules, the origin yard's loose usable pool
|
||||||
|
* otherwise. Batched — two queries for the whole schedule list.
|
||||||
|
*/
|
||||||
|
private async scheduleWagonTypeStock(
|
||||||
|
schedules: TrainSchedule[],
|
||||||
|
): Promise<Map<string, Set<string>>> {
|
||||||
|
const builtTrainIds = [
|
||||||
|
...new Set(
|
||||||
|
schedules
|
||||||
|
.map((s) => s.trainSet?.trainId)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const looseOriginYardIds = [
|
||||||
|
...new Set(
|
||||||
|
schedules
|
||||||
|
.filter((s) => !s.trainSet?.trainId)
|
||||||
|
.map((s) => s.originStationId)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [trainRows, yardRows] = await Promise.all([
|
||||||
|
builtTrainIds.length
|
||||||
|
? (this.dataSource.query(
|
||||||
|
`SELECT train_id, wagon_type_id
|
||||||
|
FROM freight.wagons
|
||||||
|
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||||
|
GROUP BY train_id, wagon_type_id`,
|
||||||
|
[builtTrainIds],
|
||||||
|
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
|
||||||
|
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
|
||||||
|
looseOriginYardIds.length
|
||||||
|
? (this.dataSource.query(
|
||||||
|
`SELECT current_yard_id, wagon_type_id
|
||||||
|
FROM freight.wagons
|
||||||
|
WHERE train_id IS NULL AND deleted_at IS NULL
|
||||||
|
AND status IN ('AVAILABLE', 'ASSIGNED')
|
||||||
|
AND current_yard_id = ANY($1::uuid[])
|
||||||
|
GROUP BY current_yard_id, wagon_type_id`,
|
||||||
|
[looseOriginYardIds],
|
||||||
|
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
|
||||||
|
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byTrain = new Map<string, Set<string>>();
|
||||||
|
for (const row of trainRows) {
|
||||||
|
const set = byTrain.get(row.train_id) ?? new Set<string>();
|
||||||
|
set.add(row.wagon_type_id);
|
||||||
|
byTrain.set(row.train_id, set);
|
||||||
|
}
|
||||||
|
const byYard = new Map<string, Set<string>>();
|
||||||
|
for (const row of yardRows) {
|
||||||
|
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
|
||||||
|
set.add(row.wagon_type_id);
|
||||||
|
byYard.set(row.current_yard_id, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, Set<string>>();
|
||||||
|
for (const s of schedules) {
|
||||||
|
const trainId = s.trainSet?.trainId;
|
||||||
|
result.set(
|
||||||
|
s.id,
|
||||||
|
trainId
|
||||||
|
? byTrain.get(trainId) ?? new Set()
|
||||||
|
: byYard.get(s.originStationId) ?? new Set(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-time gate for a chosen day: does the route have an OPEN departure
|
||||||
|
* that day at all, and can any of that day's departures physically carry the
|
||||||
|
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
|
||||||
|
* partial split offer instead).
|
||||||
|
*/
|
||||||
|
async checkDayCargoCompatibility(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
cargo: {
|
||||||
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
},
|
||||||
|
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||||
|
const schedules = await this.getBookableScheduleEntities(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
);
|
||||||
|
const onDay = schedules.filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
|
||||||
|
);
|
||||||
|
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
|
||||||
|
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
|
||||||
|
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||||
@@ -5553,6 +5893,7 @@ export class TrainSchedulingService {
|
|||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
maxWagons: schedule.maxWagons ?? null,
|
||||||
direction: schedule.direction ?? null,
|
direction: schedule.direction ?? null,
|
||||||
requiresLoadingConfirmation,
|
requiresLoadingConfirmation,
|
||||||
loadingConfirmed,
|
loadingConfirmed,
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ export class TrainBuilderService {
|
|||||||
if (!wagon || wagon.trainId !== train.id) {
|
if (!wagon || wagon.trainId !== train.id) {
|
||||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||||
}
|
}
|
||||||
if (wagon.currentTrainScheduleId) {
|
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||||
);
|
);
|
||||||
@@ -426,7 +426,7 @@ export class TrainBuilderService {
|
|||||||
if (!wagon || wagon.trainId !== train.id) {
|
if (!wagon || wagon.trainId !== train.id) {
|
||||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||||
}
|
}
|
||||||
if (wagon.currentTrainScheduleId) {
|
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||||
);
|
);
|
||||||
@@ -441,6 +441,29 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||||
|
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||||
|
* DISPATCHED) schedule has it pinned to one of its slots.
|
||||||
|
*/
|
||||||
|
private async isWagonPinnedToLiveSchedule(
|
||||||
|
manager: EntityManager,
|
||||||
|
wagonId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows: { exists: boolean }[] = await manager.query(
|
||||||
|
`SELECT TRUE AS exists
|
||||||
|
FROM freight.train_set_wagons tsw
|
||||||
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||||
|
WHERE tsw.physical_wagon_id = $1
|
||||||
|
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[wagonId],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCC bulk accept-and-execute: the subset of PENDING request ids to execute
|
||||||
|
* now. Requests not listed (or that cannot be executed) stay PENDING.
|
||||||
|
*/
|
||||||
|
export class BulkFulfillTransferRequestsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(200)
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
requestIds!: string[];
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||||
* those at fulfilment.
|
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
||||||
|
* type currently in the source yard, and a reason is mandatory.
|
||||||
*/
|
*/
|
||||||
export class CreateTransferRequestDto {
|
export class CreateTransferRequestDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
|
|||||||
@Max(1000)
|
@Max(1000)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'note', type: 'text', nullable: true })
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the wagons are needed — required for every new request and shown on
|
||||||
|
* the OCC queue. Nullable only for rows that predate the requirement.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const WAGON_STATUSES = [
|
|||||||
WagonStatus.ImportReady,
|
WagonStatus.ImportReady,
|
||||||
WagonStatus.ExportReady,
|
WagonStatus.ExportReady,
|
||||||
WagonStatus.Maintenance,
|
WagonStatus.Maintenance,
|
||||||
WagonStatus.Retired,
|
WagonStatus.Detained,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
WagonTransferHistoryAll,
|
WagonTransferHistoryAll,
|
||||||
WagonTransferRequest,
|
WagonTransferRequest,
|
||||||
} from '../../common/booking-guards';
|
} from '../../common/booking-guards';
|
||||||
|
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||||
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
|
|||||||
return this.service.listRequests(status);
|
return this.service.listRequests(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||||
|
// — Express matches in declaration order, so they would otherwise be captured
|
||||||
|
// by the `:id` param route (and rejected by ParseUUIDPipe).
|
||||||
|
@Post('bulk-fulfill')
|
||||||
|
@WagonTransferFulfill()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
|
||||||
|
})
|
||||||
|
bulkFulfill(
|
||||||
|
@Body() dto: BulkFulfillTransferRequestsDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.bulkFulfill(dto.requestIds, user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||||
// matches in declaration order, so `/history` would otherwise be captured by
|
// matches in declaration order, so `/history` would otherwise be captured by
|
||||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WagonTransferRequestStatus } from '@edr/types';
|
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
|
|||||||
private readonly wagonsService: WagonsService,
|
private readonly wagonsService: WagonsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
/**
|
||||||
|
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||||
|
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
||||||
|
* the source yard: staff may only ask for wagons that are actually there to
|
||||||
|
* give. A reason is mandatory and is shown on the OCC queue.
|
||||||
|
*/
|
||||||
async createRequest(
|
async createRequest(
|
||||||
dto: CreateTransferRequestDto,
|
dto: CreateTransferRequestDto,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
|
|||||||
'Source and destination yard must be different',
|
'Source and destination yard must be different',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
||||||
|
if (available < dto.quantity) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
available === 0
|
||||||
|
? 'No available wagons of this type in the source yard'
|
||||||
|
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const request = this.requestRepo.create({
|
const request = this.requestRepo.create({
|
||||||
fromYardId: dto.fromYardId,
|
fromYardId: dto.fromYardId,
|
||||||
toYardId: dto.toYardId,
|
toYardId: dto.toYardId,
|
||||||
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
|
|||||||
quantity: dto.quantity,
|
quantity: dto.quantity,
|
||||||
status: WagonTransferRequestStatus.Pending,
|
status: WagonTransferRequestStatus.Pending,
|
||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
|
reason: dto.reason,
|
||||||
note: dto.note ?? null,
|
note: dto.note ?? null,
|
||||||
});
|
});
|
||||||
const saved = await this.requestRepo.save(request);
|
const saved = await this.requestRepo.save(request);
|
||||||
return this.findById(saved.id);
|
return this.findById(saved.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||||
|
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||||
|
return this.wagonRepo.count({
|
||||||
|
where: {
|
||||||
|
currentYardId: yardId,
|
||||||
|
wagonTypeId,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||||
async listRequests(
|
async listRequests(
|
||||||
status?: WagonTransferRequestStatus,
|
status?: WagonTransferRequestStatus,
|
||||||
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
|
|||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
|
||||||
|
if (notAvailable.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`These wagons are not available: ${notAvailable
|
||||||
|
.map((w) => w.wagonNumber)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||||
// each stamped with this request's id so history can link them back).
|
// each stamped with this request's id so history can link them back).
|
||||||
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCC accepts AND executes a subset of pending requests in one action. For
|
||||||
|
* each selected request the system auto-picks the required number of
|
||||||
|
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
||||||
|
* number first) and runs the audited transfer. A request that cannot be
|
||||||
|
* executed — already decided, or not enough available wagons left after the
|
||||||
|
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
||||||
|
* both teams; nothing is rolled back for the others.
|
||||||
|
*/
|
||||||
|
async bulkFulfill(
|
||||||
|
requestIds: string[],
|
||||||
|
userId?: string | null,
|
||||||
|
): Promise<{
|
||||||
|
fulfilled: WagonTransferRequest[];
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
}> {
|
||||||
|
const fulfilled: WagonTransferRequest[] = [];
|
||||||
|
const skipped: Array<{ id: string; reason: string }> = [];
|
||||||
|
|
||||||
|
// Sequential on purpose: each executed transfer moves wagons out of the
|
||||||
|
// source yard, and the next request's auto-pick must see that new state.
|
||||||
|
for (const id of [...new Set(requestIds)]) {
|
||||||
|
const request = await this.requestRepo.findOne({ where: { id } });
|
||||||
|
if (!request) {
|
||||||
|
skipped.push({ id, reason: 'Request not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||||
|
skipped.push({
|
||||||
|
id,
|
||||||
|
reason: `Already ${request.status.toLowerCase()}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wagons = await this.wagonRepo.find({
|
||||||
|
where: {
|
||||||
|
currentYardId: request.fromYardId,
|
||||||
|
wagonTypeId: request.wagonTypeId,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
},
|
||||||
|
order: { wagonNumber: 'ASC' },
|
||||||
|
take: request.quantity,
|
||||||
|
});
|
||||||
|
if (wagons.length < request.quantity) {
|
||||||
|
skipped.push({
|
||||||
|
id,
|
||||||
|
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.wagonsService.bulkTransfer(
|
||||||
|
{ wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
|
||||||
|
userId,
|
||||||
|
{ transferRequestId: request.id },
|
||||||
|
);
|
||||||
|
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||||
|
request.fulfilledByUserId = userId ?? null;
|
||||||
|
request.fulfilledAt = new Date();
|
||||||
|
await this.requestRepo.save(request);
|
||||||
|
fulfilled.push(await this.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fulfilled, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||||
* individual wagons they physically moved (linked back to their request when
|
* individual wagons they physically moved (linked back to their request when
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable actor label for audit stamps (`performed_by` / `moved_by`).
|
||||||
|
* Prefers a display name, then username/email, so the activity log shows a
|
||||||
|
* person rather than a UUID. Returns undefined when there is no authenticated
|
||||||
|
* user (internal/cron calls), letting callers fall back to their prior value.
|
||||||
|
*/
|
||||||
|
export function actorLabel(user?: TCurrentUser | null): string | undefined {
|
||||||
|
if (!user) return undefined;
|
||||||
|
const name = user.name?.en?.trim() || user.name?.am?.trim();
|
||||||
|
return name || user.username || user.email || user.id || undefined;
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ export class SchedulingReadFacade {
|
|||||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||||
FROM freight.wagons
|
FROM freight.wagons
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
|
AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE')
|
||||||
ORDER BY wagon_number ASC`,
|
ORDER BY wagon_number ASC`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,80 @@ export class WarehouseFeeService {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-time dispatch rate: the share of items dispatched in the last N days that
|
||||||
|
* LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
|
||||||
|
* <= freeDays, with freeDays resolved by the same rule matching the fee engine
|
||||||
|
* uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
|
||||||
|
* is nothing to measure (e.g. no dispatched items / no storage rules).
|
||||||
|
*/
|
||||||
|
async onTimeDispatchStats(
|
||||||
|
windowDays = 90,
|
||||||
|
): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
|
||||||
|
const storageRules = (
|
||||||
|
await this.feeRuleRepository.findAll({ where: { isActive: true } })
|
||||||
|
).filter((r) => r.ruleType === 'STORAGE_FEE');
|
||||||
|
|
||||||
|
// Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
|
||||||
|
// the fields bestRule/matchScore reads, plus the two clock timestamps.
|
||||||
|
const rows: Array<
|
||||||
|
ItemAttributes & { arrivedAt: string; dispatchedAt: string }
|
||||||
|
> = await this.dataSource.query(
|
||||||
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
|
inv.dispatched_at AS "dispatchedAt",
|
||||||
|
inv.warehouse_id AS "warehouseId",
|
||||||
|
inv.yard_id AS "yardId",
|
||||||
|
inv.zone_id AS "zoneId",
|
||||||
|
w.facility_id AS "facilityId",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
|
b.trade_direction AS "tradeDirection",
|
||||||
|
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||||
|
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||||
|
NULL AS "vehicleType"
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
|
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||||
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||||
|
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
||||||
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
|
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT bc.container_type_id
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
WHERE bc.booking_id = inv.booking_id
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
AND bc.container_type_id IS NOT NULL
|
||||||
|
ORDER BY bc.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) booking_container_type ON true
|
||||||
|
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||||||
|
WHERE inv.deleted_at IS NULL
|
||||||
|
AND inv.arrived_at IS NOT NULL
|
||||||
|
AND inv.dispatched_at IS NOT NULL
|
||||||
|
AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
|
||||||
|
[windowDays],
|
||||||
|
);
|
||||||
|
|
||||||
|
let onTimeCount = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
|
||||||
|
const elapsed = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(
|
||||||
|
(new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (elapsed <= freeDays) onTimeCount += 1;
|
||||||
|
}
|
||||||
|
const sampleSize = rows.length;
|
||||||
|
return {
|
||||||
|
sampleSize,
|
||||||
|
onTimeCount,
|
||||||
|
onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
return currency === 'ETB' ? 'ETB' : 'USD';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { actorLabel } from './current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||||
@@ -73,6 +76,35 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.zoneOccupancy(yardId);
|
return this.inventoryService.zoneOccupancy(yardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('throughput')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' })
|
||||||
|
throughput(@Query('granularity') granularity?: string) {
|
||||||
|
const g = granularity === 'week' || granularity === 'year' ? granularity : 'month';
|
||||||
|
return this.inventoryService.throughput(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dwell-stats')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' })
|
||||||
|
dwellStats() {
|
||||||
|
return this.inventoryService.dwellStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('cycle-stats')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' })
|
||||||
|
cycleStats() {
|
||||||
|
return this.inventoryService.cycleStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('gate-stats')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' })
|
||||||
|
gateStats() {
|
||||||
|
return this.inventoryService.gateStats();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auto-unload-arrived')
|
@Post('auto-unload-arrived')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||||
@@ -98,7 +130,8 @@ export class WarehouseInventoryController {
|
|||||||
@Post('receive-bulk')
|
@Post('receive-bulk')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||||
return this.inventoryService.bulkReceive(dto);
|
return this.inventoryService.bulkReceive(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,15 +177,16 @@ export class WarehouseInventoryController {
|
|||||||
loadItemsOntoTrain(
|
loadItemsOntoTrain(
|
||||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
@Body() dto: { inventoryIds: string[]; performedBy?: string },
|
@Body() dto: { inventoryIds: string[]; performedBy?: string },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
|
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-dispatch-export')
|
@Post('bulk-dispatch-export')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-mark-inspected')
|
@Post('bulk-mark-inspected')
|
||||||
@@ -175,8 +209,12 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/gate-clearance')
|
@Post(':id/gate-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
gateClearance(
|
||||||
return this.inventoryService.gateClearance(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.gateClearance(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('import/arrive-queue')
|
@Get('import/arrive-queue')
|
||||||
@@ -201,10 +239,10 @@ export class WarehouseInventoryController {
|
|||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
performedBy?: string;
|
performedBy?: string;
|
||||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||||
}) {
|
}, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.autoUnloadArrivedBookings(
|
return this.inventoryService.autoUnloadArrivedBookings(
|
||||||
dto.scheduleId,
|
dto.scheduleId,
|
||||||
dto.performedBy,
|
actorLabel(user) ?? dto.performedBy,
|
||||||
dto.warehouseId,
|
dto.warehouseId,
|
||||||
dto.assignments,
|
dto.assignments,
|
||||||
);
|
);
|
||||||
@@ -246,8 +284,8 @@ export class WarehouseInventoryController {
|
|||||||
@Post('export/auto-unload-at-djibouti')
|
@Post('export/auto-unload-at-djibouti')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('import/pickup-ready-queue')
|
@Get('import/pickup-ready-queue')
|
||||||
@@ -274,14 +312,16 @@ export class WarehouseInventoryController {
|
|||||||
@Post('receive')
|
@Post('receive')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||||
return this.inventoryService.receive(dto);
|
return this.inventoryService.receive(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reserve')
|
@Post('reserve')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||||
reserve(@Body() dto: ReserveInventoryDto) {
|
reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||||
return this.inventoryService.reserve(dto);
|
return this.inventoryService.reserve(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,15 +356,19 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/store')
|
@Post(':id/store')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/ready-for-loading')
|
@Post(':id/ready-for-loading')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
readyForLoading(
|
||||||
return this.inventoryService.readyForLoading(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/load')
|
@Post(':id/load')
|
||||||
@@ -337,8 +381,12 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/ready-for-pickup')
|
@Post(':id/ready-for-pickup')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
readyForPickup(
|
||||||
return this.inventoryService.readyForPickup(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/release')
|
@Post(':id/release')
|
||||||
@@ -400,10 +448,11 @@ export class WarehouseInventoryController {
|
|||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: ApproveDeliveryDto,
|
@Body() dto: ApproveDeliveryDto,
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.inventoryService.approveDeliveryForBooking(
|
return this.inventoryService.approveDeliveryForBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
req.user?.id ?? req.user?.sub,
|
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||||
dto.signerName,
|
dto.signerName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -465,14 +514,19 @@ export class WarehouseInventoryController {
|
|||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||||
return this.inventoryService.deliver(id, dto);
|
return this.inventoryService.deliver(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/dispatch')
|
@Patch(':id/dispatch')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
dispatch(
|
||||||
return this.inventoryService.dispatch(id, performedBy);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('performedBy') performedBy: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.dispatch(id, actorLabel(user) ?? performedBy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,12 +406,14 @@ export class WarehouseInventoryService {
|
|||||||
*/
|
*/
|
||||||
async opsStats(): Promise<{
|
async opsStats(): Promise<{
|
||||||
receivedToday: number;
|
receivedToday: number;
|
||||||
|
receivedYesterday: number;
|
||||||
pendingInspection: number;
|
pendingInspection: number;
|
||||||
trucksOnSite: number;
|
trucksOnSite: number;
|
||||||
itemsAging: number;
|
itemsAging: number;
|
||||||
}> {
|
}> {
|
||||||
const [row]: Array<{
|
const [row]: Array<{
|
||||||
receivedToday: number;
|
receivedToday: number;
|
||||||
|
receivedYesterday: number;
|
||||||
pendingInspection: number;
|
pendingInspection: number;
|
||||||
trucksOnSite: number;
|
trucksOnSite: number;
|
||||||
itemsAging: number;
|
itemsAging: number;
|
||||||
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
|
|||||||
`SELECT
|
`SELECT
|
||||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||||||
|
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||||
@@ -430,12 +434,208 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
receivedToday: row?.receivedToday ?? 0,
|
receivedToday: row?.receivedToday ?? 0,
|
||||||
|
receivedYesterday: row?.receivedYesterday ?? 0,
|
||||||
pendingInspection: row?.pendingInspection ?? 0,
|
pendingInspection: row?.pendingInspection ?? 0,
|
||||||
trucksOnSite: row?.trucksOnSite ?? 0,
|
trucksOnSite: row?.trucksOnSite ?? 0,
|
||||||
itemsAging: row?.itemsAging ?? 0,
|
itemsAging: row?.itemsAging ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||||||
|
private readonly IN_WAREHOUSE_STATUSES = [
|
||||||
|
'RECEIVED',
|
||||||
|
'UNLOADED',
|
||||||
|
'STORED',
|
||||||
|
'RESERVED',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dwell time of items still in the warehouse: average days held plus a count
|
||||||
|
* per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
|
||||||
|
* back to created_at). Powers the dwell / aging histogram.
|
||||||
|
*/
|
||||||
|
async dwellStats(): Promise<{
|
||||||
|
avgDwellDays: number;
|
||||||
|
inWarehouseCount: number;
|
||||||
|
buckets: Array<{ key: string; label: string; count: number }>;
|
||||||
|
}> {
|
||||||
|
const [row]: Array<{
|
||||||
|
avgDwellDays: number | null;
|
||||||
|
inWarehouseCount: number;
|
||||||
|
b0: number;
|
||||||
|
b1: number;
|
||||||
|
b2: number;
|
||||||
|
b3: number;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`WITH held AS (
|
||||||
|
SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status = ANY($1)
|
||||||
|
)
|
||||||
|
SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
|
||||||
|
count(*)::int AS "inWarehouseCount",
|
||||||
|
count(*) FILTER (WHERE age_days < 4)::int AS b0,
|
||||||
|
count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
|
||||||
|
count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
|
||||||
|
count(*) FILTER (WHERE age_days >= 15)::int AS b3
|
||||||
|
FROM held`,
|
||||||
|
[this.IN_WAREHOUSE_STATUSES],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
avgDwellDays: row?.avgDwellDays ?? 0,
|
||||||
|
inWarehouseCount: row?.inWarehouseCount ?? 0,
|
||||||
|
buckets: [
|
||||||
|
{ key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
|
||||||
|
{ key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
|
||||||
|
{ key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
|
||||||
|
{ key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average stage cycle times over items dispatched in the last 90 days:
|
||||||
|
* arrived→ready, ready→loaded, loaded→dispatched, and the total
|
||||||
|
* arrived→dispatched (dock-to-dispatch). Days, to one decimal.
|
||||||
|
*/
|
||||||
|
async cycleStats(): Promise<{
|
||||||
|
sampleSize: number;
|
||||||
|
avgDockToDispatchDays: number;
|
||||||
|
stages: Array<{ key: string; label: string; avgDays: number }>;
|
||||||
|
}> {
|
||||||
|
const gapDays = (from: string, to: string) =>
|
||||||
|
`round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
|
||||||
|
const [row]: Array<{
|
||||||
|
sampleSize: number;
|
||||||
|
total: number | null;
|
||||||
|
arrivedReady: number | null;
|
||||||
|
readyLoaded: number | null;
|
||||||
|
loadedDispatched: number | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS "sampleSize",
|
||||||
|
${gapDays('arrived_at', 'dispatched_at')} AS "total",
|
||||||
|
${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
|
||||||
|
${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
|
||||||
|
${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND arrived_at IS NOT NULL
|
||||||
|
AND dispatched_at IS NOT NULL
|
||||||
|
AND dispatched_at > now() - interval '90 days'`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sampleSize: row?.sampleSize ?? 0,
|
||||||
|
avgDockToDispatchDays: row?.total ?? 0,
|
||||||
|
stages: [
|
||||||
|
{ key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
|
||||||
|
{ key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
|
||||||
|
{ key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate / dock throughput: items cleared through the gate today, the average
|
||||||
|
* arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
|
||||||
|
* bucketed per hour over the last 24 hours. Powers the gate throughput card.
|
||||||
|
*/
|
||||||
|
async gateStats(): Promise<{
|
||||||
|
clearedToday: number;
|
||||||
|
avgTurnaroundHours: number | null;
|
||||||
|
byHour: Array<{ hour: string; count: number }>;
|
||||||
|
}> {
|
||||||
|
const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT
|
||||||
|
count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
|
||||||
|
round(
|
||||||
|
avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
|
||||||
|
FILTER (
|
||||||
|
WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
|
||||||
|
AND gate_cleared_at > now() - interval '30 days'
|
||||||
|
)::numeric,
|
||||||
|
1
|
||||||
|
)::float8 AS "avgTurnaroundHours"
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
|
||||||
|
`WITH hours AS (
|
||||||
|
SELECT gs AS h
|
||||||
|
FROM generate_series(
|
||||||
|
date_trunc('hour', now()) - interval '23 hours',
|
||||||
|
date_trunc('hour', now()),
|
||||||
|
interval '1 hour'
|
||||||
|
) gs
|
||||||
|
)
|
||||||
|
SELECT to_char(hours.h, 'HH24:00') AS hour,
|
||||||
|
COALESCE(g.cnt, 0)::int AS count
|
||||||
|
FROM hours
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
) g ON g.ph = hours.h
|
||||||
|
ORDER BY hours.h`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
clearedToday: scalar?.clearedToday ?? 0,
|
||||||
|
avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
|
||||||
|
byHour,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Received-vs-dispatched throughput as a server-side time series. Buckets by
|
||||||
|
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
|
||||||
|
* generate_series so empty periods still return a zero row — replaces the
|
||||||
|
* client-side approach that downloaded the whole inventory to bucket it.
|
||||||
|
*/
|
||||||
|
async throughput(
|
||||||
|
granularity: 'week' | 'month' | 'year' = 'month',
|
||||||
|
): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
|
||||||
|
// Whitelist the unit — it is interpolated into date_trunc / interval literals.
|
||||||
|
const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
|
||||||
|
? granularity
|
||||||
|
: 'month';
|
||||||
|
const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
|
||||||
|
|
||||||
|
const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`WITH periods AS (
|
||||||
|
SELECT gs AS period_start
|
||||||
|
FROM generate_series(
|
||||||
|
date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
|
||||||
|
date_trunc('${unit}', now()),
|
||||||
|
'1 ${unit}'::interval
|
||||||
|
) gs
|
||||||
|
)
|
||||||
|
SELECT p.period_start AS "periodStart",
|
||||||
|
COALESCE(r.cnt, 0)::int AS received,
|
||||||
|
COALESCE(d.cnt, 0)::int AS dispatched
|
||||||
|
FROM periods p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
) r ON r.ps = p.period_start
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
|
||||||
|
GROUP BY 1
|
||||||
|
) d ON d.ps = p.period_start
|
||||||
|
ORDER BY p.period_start`,
|
||||||
|
[back],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { actorLabel } from './current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||||
@@ -17,7 +20,8 @@ export class WarehouseInvoiceController {
|
|||||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
|
||||||
|
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||||
return this.invoiceService.generateForInventory(id, dto);
|
return this.invoiceService.generateForInventory(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ export class WarehouseRulesController {
|
|||||||
return this.feeService.accrualDashboard(billingCurrency);
|
return this.feeService.accrualDashboard(billingCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-fees/on-time-dispatch')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||||
|
@ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' })
|
||||||
|
onTimeDispatch() {
|
||||||
|
return this.feeService.onTimeDispatchStats();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||||
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
FilePen,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
|
||||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||||
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { status } = contract;
|
const { status } = contract;
|
||||||
|
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
const [changesOpen, setChangesOpen] = useState(false);
|
const [changesOpen, setChangesOpen] = useState(false);
|
||||||
const [changesNote, setChangesNote] = useState("");
|
const [changesNote, setChangesNote] = useState("");
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
|
|||||||
.map((o) => ({ value: String(o.value), label: o.label })),
|
.map((o) => ({ value: String(o.value), label: o.label })),
|
||||||
[validitySetting],
|
[validitySetting],
|
||||||
);
|
);
|
||||||
// Default the selection to the first configured option when the dialog opens.
|
|
||||||
useEffect(() => {
|
|
||||||
if (acceptOpen && !validityDays && validityOptions.length > 0) {
|
|
||||||
setValidityDays(validityOptions[0].value);
|
|
||||||
}
|
|
||||||
}, [acceptOpen, validityDays, validityOptions]);
|
|
||||||
|
|
||||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canAccept = status === "SUBMITTED";
|
const canAccept = status === "SUBMITTED";
|
||||||
// Generation only becomes available once EVERY approval step is complete and
|
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
|
||||||
// the contract reaches APPROVED. While any step is still pending the contract
|
// can edit this contract's articles and (re)generate its PDF. The first
|
||||||
// stays in PENDING_APPROVAL, so this button does not appear after only the
|
// approval action locks the document.
|
||||||
// first (line-staff) approval — the director step must land first.
|
const docLocked =
|
||||||
|
status !== "PENDING_APPROVAL" ||
|
||||||
|
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
|
||||||
|
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
|
||||||
|
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||||
|
// Legacy fallback: if a contract ever lands on APPROVED without a document
|
||||||
|
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
|
||||||
const needsManualGenerate =
|
const needsManualGenerate =
|
||||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||||
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
|
|||||||
fullWidth
|
fullWidth
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Check size={16} />}
|
leftSection={<Check size={16} />}
|
||||||
onClick={() => setAcceptOpen(true)}
|
onClick={() => {
|
||||||
|
setEditorMode("accept");
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Accept for approval
|
Accept for approval
|
||||||
</Button>
|
</Button>
|
||||||
@@ -156,6 +154,43 @@ export function ContractActionsToolbar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEditGenerate && (
|
||||||
|
<>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{documentGenerated
|
||||||
|
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
|
||||||
|
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<FilePen size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditorMode("edit");
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit contract articles
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={
|
||||||
|
documentGenerated ? (
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={mutations.generateContract.isPending}
|
||||||
|
onClick={() => mutations.generateContract.mutate()}
|
||||||
|
>
|
||||||
|
{documentGenerated ? "Regenerate contract" : "Generate contract"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{needsManualGenerate && (
|
{needsManualGenerate && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -198,6 +233,7 @@ export function ContractActionsToolbar({
|
|||||||
the customer creates the booking in the portal. */}
|
the customer creates the booking in the portal. */}
|
||||||
|
|
||||||
{!canAccept &&
|
{!canAccept &&
|
||||||
|
!canEditGenerate &&
|
||||||
!needsManualGenerate &&
|
!needsManualGenerate &&
|
||||||
!canViewContract &&
|
!canViewContract &&
|
||||||
!canReviewClearance && (
|
!canReviewClearance && (
|
||||||
@@ -208,62 +244,28 @@ export function ContractActionsToolbar({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Accept — sets the contract validity window */}
|
{/* Accept / edit — review + optionally edit this contract's articles */}
|
||||||
<Modal
|
<ContractDocumentEditorModal
|
||||||
opened={acceptOpen}
|
opened={editorOpen}
|
||||||
onClose={() => setAcceptOpen(false)}
|
onClose={() => setEditorOpen(false)}
|
||||||
title="Accept contract for approval"
|
contractId={contract.id}
|
||||||
centered
|
mode={editorMode}
|
||||||
>
|
validityOptions={validityOptions}
|
||||||
<Stack gap="md">
|
validityLoading={validityLoading}
|
||||||
<Text size="sm" c="dimmed">
|
accepting={mutations.staffAccept.isPending}
|
||||||
Pick the contract validity window, then start the approval chain.
|
saving={mutations.updateDocument.isPending}
|
||||||
</Text>
|
onAccept={(days, snapshot) =>
|
||||||
{validityOptions.length > 0 ? (
|
mutations.staffAccept.mutate(
|
||||||
<Select
|
{ validityDays: days, documentSnapshot: snapshot },
|
||||||
label="Validity"
|
{ onSuccess: () => setEditorOpen(false) },
|
||||||
placeholder="Select a validity period"
|
)
|
||||||
data={validityOptions}
|
}
|
||||||
value={validityDays}
|
onSaveEdit={(snapshot) =>
|
||||||
onChange={setValidityDays}
|
mutations.updateDocument.mutate(snapshot, {
|
||||||
allowDeselect={false}
|
onSuccess: () => setEditorOpen(false),
|
||||||
comboboxProps={{ withinPortal: true }}
|
})
|
||||||
/>
|
}
|
||||||
) : (
|
/>
|
||||||
<Text size="sm" c="orange.7">
|
|
||||||
{validityLoading
|
|
||||||
? "Loading validity periods…"
|
|
||||||
: "No validity periods are configured yet. Add them under "}
|
|
||||||
{!validityLoading && (
|
|
||||||
<Anchor
|
|
||||||
href="/dashboard/dropdown-settings"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
navigate("/dashboard/dropdown-settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dropdown Settings
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
{!validityLoading && "."}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={mutations.staffAccept.isPending}
|
|
||||||
disabled={!validityDays}
|
|
||||||
onClick={() => {
|
|
||||||
const days = Number(validityDays);
|
|
||||||
if (!days) return;
|
|
||||||
mutations.staffAccept.mutate(days, {
|
|
||||||
onSuccess: () => setAcceptOpen(false),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Request changes */}
|
{/* Request changes */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
Lock,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
|
/** New client-side article id (server keeps whatever id we send). */
|
||||||
|
function newArticleId(): string {
|
||||||
|
const c = globalThis.crypto;
|
||||||
|
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||||
|
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditableArticle {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractDocumentEditorModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: string;
|
||||||
|
/**
|
||||||
|
* "accept" — shown from the Accept-for-approval action: pick a validity window
|
||||||
|
* and (optionally) edit the articles, then start the approval chain.
|
||||||
|
* "edit" — re-edit the frozen articles of an already-accepted contract before
|
||||||
|
* generating/regenerating its PDF.
|
||||||
|
*/
|
||||||
|
mode: "accept" | "edit";
|
||||||
|
/** Validity options (accept mode only). */
|
||||||
|
validityOptions?: Array<{ value: string; label: string }>;
|
||||||
|
validityLoading?: boolean;
|
||||||
|
accepting?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onAccept?: (
|
||||||
|
validityDays: number,
|
||||||
|
snapshot: Freight.IContractDocumentSnapshot,
|
||||||
|
) => void;
|
||||||
|
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-contract contract-document editor. Loads the resolved template (or this
|
||||||
|
* contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
|
||||||
|
* for THIS contract only — it never writes back to the shared six templates.
|
||||||
|
*/
|
||||||
|
export function ContractDocumentEditorModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
mode,
|
||||||
|
validityOptions = [],
|
||||||
|
validityLoading = false,
|
||||||
|
accepting = false,
|
||||||
|
saving = false,
|
||||||
|
onAccept,
|
||||||
|
onSaveEdit,
|
||||||
|
}: ContractDocumentEditorModalProps) {
|
||||||
|
const { data: draft, isLoading } = useQuery({
|
||||||
|
queryKey: ["contracts", contractId, "document-draft"],
|
||||||
|
queryFn: () => contractsService.getContractDocumentDraft(contractId),
|
||||||
|
enabled: opened && Boolean(contractId),
|
||||||
|
// Always refetch the current draft when the dialog opens.
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [documentTitle, setDocumentTitle] = useState("");
|
||||||
|
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
|
||||||
|
const [articles, setArticles] = useState<EditableArticle[]>([]);
|
||||||
|
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Seed the editor from the loaded draft whenever the dialog (re)opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !draft) return;
|
||||||
|
setDocumentTitle(draft.documentTitle ?? "");
|
||||||
|
setWhereasClauses(draft.whereasClauses ?? []);
|
||||||
|
setArticles(
|
||||||
|
(draft.articles ?? []).map((a) => ({
|
||||||
|
id: a.id || newArticleId(),
|
||||||
|
title: a.title,
|
||||||
|
body: a.body,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [opened, draft]);
|
||||||
|
|
||||||
|
// Default validity to the first configured option (accept mode).
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||||
|
setValidityDays(validityOptions[0].value);
|
||||||
|
}
|
||||||
|
}, [mode, validityDays, validityOptions]);
|
||||||
|
|
||||||
|
const locked = mode === "edit" && Boolean(draft?.locked);
|
||||||
|
|
||||||
|
const moveArticle = (index: number, delta: number) => {
|
||||||
|
setArticles((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= next.length) return prev;
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||||
|
setArticles((prev) =>
|
||||||
|
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeArticle = (id: string) =>
|
||||||
|
setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
|
||||||
|
const addArticle = () =>
|
||||||
|
setArticles((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: newArticleId(), title: "", body: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
||||||
|
code: draft?.code ?? null,
|
||||||
|
name: draft?.name ?? null,
|
||||||
|
documentTitle: documentTitle.trim() || null,
|
||||||
|
whereasClauses: whereasClauses
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter((c) => c.length > 0),
|
||||||
|
articles: articles
|
||||||
|
.filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
|
||||||
|
.map((a, index) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title.trim(),
|
||||||
|
body: a.body,
|
||||||
|
order: index + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasArticles = useMemo(
|
||||||
|
() => articles.some((a) => a.title.trim() || a.body.trim()),
|
||||||
|
[articles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const snapshot = buildSnapshot();
|
||||||
|
if (mode === "accept") {
|
||||||
|
const days = Number(validityDays);
|
||||||
|
if (!days) return;
|
||||||
|
onAccept?.(days, snapshot);
|
||||||
|
} else {
|
||||||
|
onSaveEdit?.(snapshot);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitting = accepting || saving;
|
||||||
|
const canSubmit =
|
||||||
|
hasArticles &&
|
||||||
|
!locked &&
|
||||||
|
(mode === "edit" || Boolean(validityDays)) &&
|
||||||
|
!submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<FileText size={18} />
|
||||||
|
<Text fw={700}>
|
||||||
|
{mode === "accept"
|
||||||
|
? "Review contract document & accept"
|
||||||
|
: "Edit contract document"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" color="gray" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading document…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color={locked ? "orange" : "blue"}
|
||||||
|
icon={locked ? <Lock size={16} /> : <Info size={16} />}
|
||||||
|
>
|
||||||
|
{locked
|
||||||
|
? "This document is locked — an approver has already acted, so it can no longer be edited."
|
||||||
|
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Document title"
|
||||||
|
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
||||||
|
value={documentTitle}
|
||||||
|
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
||||||
|
disabled={locked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
WHEREAS recitals
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Plus size={13} />}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => setWhereasClauses((p) => [...p, ""])}
|
||||||
|
>
|
||||||
|
Add recital
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{whereasClauses.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
No recitals.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{whereasClauses.map((clause, i) => (
|
||||||
|
<Group key={i} gap="xs" wrap="nowrap" align="flex-start">
|
||||||
|
<Textarea
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
autosize
|
||||||
|
minRows={1}
|
||||||
|
value={clause}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWhereasClauses((prev) =>
|
||||||
|
prev.map((c, idx) =>
|
||||||
|
idx === i ? e.currentTarget.value : c,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() =>
|
||||||
|
setWhereasClauses((prev) =>
|
||||||
|
prev.filter((_, idx) => idx !== i),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider label="Articles" labelPosition="left" />
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
{articles.map((article, index) => (
|
||||||
|
<Box
|
||||||
|
key={article.id}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
|
Article {index + 1}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Move up" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
disabled={locked || index === 0}
|
||||||
|
onClick={() => moveArticle(index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Move down" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
disabled={locked || index === articles.length - 1}
|
||||||
|
onClick={() => moveArticle(index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete article" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => removeArticle(article.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Article title"
|
||||||
|
value={article.title}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateArticle(article.id, { title: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||||
|
value={article.body}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateArticle(article.id, { body: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={15} />}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={addArticle}
|
||||||
|
>
|
||||||
|
Add article
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{mode === "accept" && (
|
||||||
|
<>
|
||||||
|
{validityOptions.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
label="Contract validity"
|
||||||
|
placeholder="Select a validity period"
|
||||||
|
data={validityOptions}
|
||||||
|
value={validityDays}
|
||||||
|
onChange={setValidityDays}
|
||||||
|
allowDeselect={false}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="orange.7">
|
||||||
|
{validityLoading
|
||||||
|
? "Loading validity periods…"
|
||||||
|
: "No validity periods are configured yet. Add them under Dropdown Settings."}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{mode === "accept"
|
||||||
|
? "Accept & start approval"
|
||||||
|
: "Save document changes"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export const formatFleetCell = (
|
|||||||
if (s === "INACTIVE") return "gray";
|
if (s === "INACTIVE") return "gray";
|
||||||
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
||||||
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
||||||
if (s === "RETIRED") return "gray";
|
if (s === "RETIRED" || s === "DETAINED") return "gray";
|
||||||
return "gray";
|
return "gray";
|
||||||
};
|
};
|
||||||
const color = getStatusColor(status);
|
const color = getStatusColor(status);
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface KpiItem {
|
|||||||
* into semantic tints.
|
* into semantic tints.
|
||||||
*/
|
*/
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/**
|
||||||
|
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
|
||||||
|
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
|
||||||
|
*/
|
||||||
|
delta?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KpiStripProps {
|
export interface KpiStripProps {
|
||||||
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton height={26} width={72} radius="sm" my={2} />
|
<Skeleton height={26} width={72} radius="sm" my={2} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<div className="flex items-baseline gap-2">
|
||||||
fw={800}
|
<Text
|
||||||
fz={24}
|
fw={800}
|
||||||
lh={1.05}
|
fz={24}
|
||||||
c="edr-text"
|
lh={1.05}
|
||||||
style={{ letterSpacing: "-0.02em" }}
|
c="edr-text"
|
||||||
truncate
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
>
|
truncate
|
||||||
{item.value}
|
>
|
||||||
</Text>
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
{item.delta != null && item.delta !== 0 ? (
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
fz="xs"
|
||||||
|
fw={700}
|
||||||
|
c={item.delta > 0 ? "edr-green" : "red"}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{item.delta > 0 ? "▲" : "▼"}
|
||||||
|
{Math.abs(item.delta)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" fw={600} c="edr-muted" truncate>
|
<Text size="xs" fw={600} c="edr-muted" truncate>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ function HistoryPanel({ opened }: { opened: boolean }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
{r.reason ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
Reason: {r.reason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -233,6 +238,8 @@ const WagonTransferRequestsModal = ({
|
|||||||
const [tab, setTab] = useState<string | null>("queue");
|
const [tab, setTab] = useState<string | null>("queue");
|
||||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: requests = [], isLoading } = useQuery({
|
const { data: requests = [], isLoading } = useQuery({
|
||||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||||
@@ -256,6 +263,9 @@ const WagonTransferRequestsModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||||
|
const bulkFulfill = useMutation(
|
||||||
|
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
|
||||||
|
);
|
||||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||||
|
|
||||||
const showError = (err: unknown, fallback: string) => {
|
const showError = (err: unknown, fallback: string) => {
|
||||||
@@ -310,6 +320,36 @@ const WagonTransferRequestsModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSelected = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute the ticked subset; whatever cannot run (not enough available
|
||||||
|
// wagons, already decided) is reported and simply stays PENDING.
|
||||||
|
const handleBulkFulfill = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
try {
|
||||||
|
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
||||||
|
setSelected(new Set());
|
||||||
|
const skippedNote = res.skipped.length
|
||||||
|
? ` · ${res.skipped.length} left pending (${res.skipped
|
||||||
|
.map((s) => s.reason)
|
||||||
|
.join('; ')})`
|
||||||
|
: "";
|
||||||
|
toast({
|
||||||
|
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
||||||
|
description: skippedNote || undefined,
|
||||||
|
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Bulk execute failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sortedWagons = useMemo(
|
const sortedWagons = useMemo(
|
||||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||||
[wagons],
|
[wagons],
|
||||||
@@ -371,17 +411,65 @@ const WagonTransferRequestsModal = ({
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
||||||
|
everything unticked (or unexecutable) stays PENDING. */}
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Checkbox
|
||||||
|
label={
|
||||||
|
selected.size > 0
|
||||||
|
? `${selected.size} of ${requests.length} selected`
|
||||||
|
: "Select all"
|
||||||
|
}
|
||||||
|
checked={selected.size === requests.length && requests.length > 0}
|
||||||
|
indeterminate={selected.size > 0 && selected.size < requests.length}
|
||||||
|
onChange={() =>
|
||||||
|
setSelected(
|
||||||
|
selected.size === requests.length
|
||||||
|
? new Set()
|
||||||
|
: new Set(requests.map((r) => r.id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<PackageCheck size={14} />}
|
||||||
|
loading={bulkFulfill.isPending}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onClick={handleBulkFulfill}
|
||||||
|
>
|
||||||
|
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{requests.map((r) => (
|
{requests.map((r) => (
|
||||||
<Card key={r.id} withBorder radius="md" padding="md">
|
<Card key={r.id} withBorder radius="md" padding="md">
|
||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||||
<RequestSummary r={r} />
|
<Checkbox
|
||||||
{r.note ? (
|
checked={selected.has(r.id)}
|
||||||
<Text size="xs" c="dimmed">
|
onChange={() => toggleSelected(r.id)}
|
||||||
“{r.note}”
|
color="edr-green"
|
||||||
</Text>
|
mt={2}
|
||||||
) : null}
|
/>
|
||||||
</Stack>
|
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||||
|
<RequestSummary r={r} />
|
||||||
|
{r.reason ? (
|
||||||
|
<Text size="xs">
|
||||||
|
<Text span fw={600}>
|
||||||
|
Reason:
|
||||||
|
</Text>{" "}
|
||||||
|
{r.reason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{r.note ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
“{r.note}”
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Slider,
|
Slider,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
@@ -125,6 +126,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
|
|
||||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||||
const [transferQty, setTransferQty] = useState(0);
|
const [transferQty, setTransferQty] = useState(0);
|
||||||
|
const [transferReason, setTransferReason] = useState("");
|
||||||
const [toAssignedQty, setToAssignedQty] = useState(0);
|
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||||
const [toAvailableQty, setToAvailableQty] = useState(0);
|
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||||
|
|
||||||
@@ -207,6 +209,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTransferYardId(null);
|
setTransferYardId(null);
|
||||||
setTransferQty(0);
|
setTransferQty(0);
|
||||||
|
setTransferReason("");
|
||||||
setToAssignedQty(0);
|
setToAssignedQty(0);
|
||||||
setToAvailableQty(0);
|
setToAvailableQty(0);
|
||||||
}, [yardId, typeId]);
|
}, [yardId, typeId]);
|
||||||
@@ -219,8 +222,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
}
|
}
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
// Keep quantities within bounds as counts shift after each action.
|
// Keep quantities within bounds as counts shift after each action. Transfers
|
||||||
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
|
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
||||||
|
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||||
|
|
||||||
@@ -233,13 +237,21 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
// Request-only: the requester specifies count + destination; OCC later picks
|
// Request-only: the requester specifies count + destination; OCC later picks
|
||||||
// the physical wagons and executes the move. No wagons are moved here.
|
// the physical wagons and executes the move. No wagons are moved here.
|
||||||
const handleRequest = async () => {
|
const handleRequest = async () => {
|
||||||
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
|
if (
|
||||||
|
!yardId ||
|
||||||
|
!typeId ||
|
||||||
|
!transferYardId ||
|
||||||
|
transferQty < 1 ||
|
||||||
|
!transferReason.trim()
|
||||||
|
)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await createRequest.mutateAsync({
|
await createRequest.mutateAsync({
|
||||||
fromYardId: yardId,
|
fromYardId: yardId,
|
||||||
toYardId: transferYardId,
|
toYardId: transferYardId,
|
||||||
wagonTypeId: typeId,
|
wagonTypeId: typeId,
|
||||||
quantity: transferQty,
|
quantity: transferQty,
|
||||||
|
reason: transferReason.trim(),
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||||
@@ -249,6 +261,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
});
|
});
|
||||||
setTransferQty(0);
|
setTransferQty(0);
|
||||||
setTransferYardId(null);
|
setTransferYardId(null);
|
||||||
|
setTransferReason("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err, "Request failed");
|
showError(err, "Request failed");
|
||||||
}
|
}
|
||||||
@@ -407,10 +420,19 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
</Text>
|
</Text>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} mb={4}>
|
<Group justify="space-between" mb={4}>
|
||||||
How many wagons
|
<Text size="sm" fw={500}>
|
||||||
</Text>
|
How many wagons
|
||||||
<QuantityField value={transferQty} onChange={setTransferQty} max={total} />
|
</Text>
|
||||||
|
<Badge color="teal" variant="light">
|
||||||
|
{availableCount} available
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<QuantityField
|
||||||
|
value={transferQty}
|
||||||
|
onChange={setTransferQty}
|
||||||
|
max={availableCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="Destination yard"
|
label="Destination yard"
|
||||||
@@ -421,6 +443,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
searchable
|
searchable
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
placeholder="Why are these wagons needed?"
|
||||||
|
value={transferReason}
|
||||||
|
onChange={(e) => setTransferReason(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
{transferYardId && transferQty > 0 ? (
|
{transferYardId && transferQty > 0 ? (
|
||||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
@@ -446,7 +478,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
leftSection={<ArrowRightLeft size={16} />}
|
leftSection={<ArrowRightLeft size={16} />}
|
||||||
onClick={handleRequest}
|
onClick={handleRequest}
|
||||||
loading={createRequest.isPending}
|
loading={createRequest.isPending}
|
||||||
disabled={busy || !transferYardId || transferQty < 1}
|
disabled={
|
||||||
|
busy ||
|
||||||
|
!transferYardId ||
|
||||||
|
transferQty < 1 ||
|
||||||
|
!transferReason.trim()
|
||||||
|
}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
>
|
>
|
||||||
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import { Gauge } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useOnTimeDispatch, useWarehouseCycleStats } from '@/hooks/useWarehouses';
|
||||||
|
import { formatDays } from './options';
|
||||||
|
|
||||||
|
function onTimeColor(pct: number | null | undefined): string {
|
||||||
|
if (pct == null) return 'edr-text';
|
||||||
|
if (pct >= 80) return 'teal';
|
||||||
|
if (pct >= 50) return 'orange';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warehouse performance: on-time dispatch rate (items that left before their
|
||||||
|
* storage free-days expired), average dock-to-dispatch, and the per-stage
|
||||||
|
* cycle times that make it up.
|
||||||
|
*/
|
||||||
|
export function CycleTimeCard() {
|
||||||
|
const { data: cycle, isLoading: cycleLoading } = useWarehouseCycleStats();
|
||||||
|
const { data: onTime, isLoading: onTimeLoading } = useOnTimeDispatch();
|
||||||
|
const isLoading = cycleLoading || onTimeLoading;
|
||||||
|
|
||||||
|
const hasStages = (cycle?.sampleSize ?? 0) > 0;
|
||||||
|
const stages = cycle?.stages ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||||
|
<Group gap="sm" mb="md">
|
||||||
|
<ThemeIcon size="lg" radius="md" color="teal" variant="light">
|
||||||
|
<Gauge size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>Cycle time & on-time</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Dispatch performance over the last 90 days
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" h={220}>
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
On-time dispatch
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={30} lh={1.1} c={onTimeColor(onTime?.onTimePct)}>
|
||||||
|
{onTime?.onTimePct == null ? 'N/A' : `${onTime.onTimePct}%`}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{onTime?.onTimePct == null
|
||||||
|
? 'No storage rule / sample'
|
||||||
|
: `${onTime.onTimeCount}/${onTime.sampleSize} left before free-days`}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Dock → dispatch
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={30} lh={1.1}>
|
||||||
|
{hasStages ? formatDays(cycle?.avgDockToDispatchDays) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
avg over {cycle?.sampleSize ?? 0} dispatched
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{hasStages ? (
|
||||||
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
|
<BarChart
|
||||||
|
data={stages}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 12 }} unit="d" />
|
||||||
|
<YAxis type="category" dataKey="label" width={130} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'var(--mantine-color-gray-1)' }}
|
||||||
|
formatter={(value) => [`${value} days`, 'Avg'] as [string, string]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="avgDays" name="Avg days" fill="#12b886" radius={[0, 6, 6, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center" align="center" h={170}>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Not enough dispatched items yet to chart stage times.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Card, Group, Loader, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import { Hourglass } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useWarehouseDwellStats } from '@/hooks/useWarehouses';
|
||||||
|
import { formatDays } from './options';
|
||||||
|
|
||||||
|
/** Green → amber → red as items age. Aligned with the zone-occupancy heat scale. */
|
||||||
|
const BUCKET_COLORS = ['#12b886', '#40c057', '#f08c00', '#fa5252'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dwell time of items still in the warehouse: the average, plus how the current
|
||||||
|
* stock is spread across aging buckets (0–3 / 4–7 / 8–14 / 15+ days).
|
||||||
|
*/
|
||||||
|
export function DwellAgingCard() {
|
||||||
|
const { data, isLoading } = useWarehouseDwellStats();
|
||||||
|
const buckets = data?.buckets ?? [];
|
||||||
|
const hasItems = (data?.inWarehouseCount ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||||
|
<Group gap="sm" mb="md">
|
||||||
|
<ThemeIcon size="lg" radius="md" color="grape" variant="light">
|
||||||
|
<Hourglass size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>Dwell time & aging</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
How long current stock has been held
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" h={220}>
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group align="stretch" gap="lg" wrap="nowrap">
|
||||||
|
<Stack gap={2} justify="center" style={{ minWidth: 110 }}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Avg dwell
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={30} lh={1.1}>
|
||||||
|
{hasItems ? formatDays(data?.avgDwellDays) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{data?.inWarehouseCount ?? 0} item{(data?.inWarehouseCount ?? 0) === 1 ? '' : 's'} in warehouse
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{hasItems ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart
|
||||||
|
data={buckets}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis type="category" dataKey="label" width={72} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||||
|
<Bar dataKey="count" name="Items" radius={[0, 6, 6, 0]}>
|
||||||
|
{buckets.map((b, i) => (
|
||||||
|
<Cell key={b.key} fill={BUCKET_COLORS[i] ?? '#868e96'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center" align="center" h={200}>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
No items currently in the warehouse.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import { DoorOpen } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { useWarehouseGateStats } from '@/hooks/useWarehouses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate / dock throughput: items cleared through the gate today, the average
|
||||||
|
* arrival→gate-clearance turnaround, and clearances per hour over the last 24h.
|
||||||
|
*/
|
||||||
|
export function GateThroughputCard() {
|
||||||
|
const { data, isLoading } = useWarehouseGateStats();
|
||||||
|
const byHour = data?.byHour ?? [];
|
||||||
|
const hasActivity = byHour.some((h) => h.count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding="lg" h="100%">
|
||||||
|
<Group gap="sm" mb="md">
|
||||||
|
<ThemeIcon size="lg" radius="md" color="indigo" variant="light">
|
||||||
|
<DoorOpen size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>Gate & dock throughput</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Gate clearances over the last 24 hours
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" h={220}>
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Cleared today
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={30} lh={1.1}>
|
||||||
|
{data?.clearedToday ?? 0}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
through the gate
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Avg turnaround
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={30} lh={1.1}>
|
||||||
|
{data?.avgTurnaroundHours == null ? '—' : `${data.avgTurnaroundHours} h`}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
arrival → gate (30d)
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{hasActivity ? (
|
||||||
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
|
<BarChart data={byHour} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||||
|
<XAxis dataKey="hour" interval={3} tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||||
|
<Bar dataKey="count" name="Cleared" fill="#4c6ef5" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center" align="center" h={170}>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
No gate clearances in the last 24 hours.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
|
import { BarChart3, CalendarRange } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
Legend,
|
Legend,
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useWarehouseThroughput } from '@/hooks/useWarehouses';
|
||||||
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
import { api } from '@/services/api';
|
|
||||||
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
|
|
||||||
|
|
||||||
interface WarehouseDashboardChartsProps {
|
interface WarehouseDashboardChartsProps {
|
||||||
data?: WarehouseDashboard;
|
data?: WarehouseDashboard;
|
||||||
@@ -38,11 +34,25 @@ const STATUS_SERIES = [
|
|||||||
|
|
||||||
type Granularity = 'week' | 'month' | 'year';
|
type Granularity = 'week' | 'month' | 'year';
|
||||||
|
|
||||||
|
/** Label a period start according to the selected granularity. */
|
||||||
|
function formatPeriod(iso: string, granularity: Granularity): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (granularity === 'year') return String(d.getFullYear());
|
||||||
|
if (granularity === 'week') return d.toLocaleDateString('en', { day: 'numeric', month: 'short' });
|
||||||
|
return d.toLocaleDateString('en', { month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
|
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
|
||||||
const [granularity, setGranularity] = useState<Granularity>('month');
|
const [granularity, setGranularity] = useState<Granularity>('month');
|
||||||
const { data: inventory } = useQuery(
|
// Server-side time series (replaces downloading the whole inventory to bucket).
|
||||||
api.warehouses.listInventory.queryOptions({ input: {} }),
|
const { data: series = [] } = useWarehouseThroughput(granularity);
|
||||||
);
|
|
||||||
|
const trend = series.map((p) => ({
|
||||||
|
label: formatPeriod(p.periodStart, granularity),
|
||||||
|
received: p.received,
|
||||||
|
dispatched: p.dispatched,
|
||||||
|
}));
|
||||||
|
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
|
||||||
|
|
||||||
const statusData = STATUS_SERIES.map((s) => ({
|
const statusData = STATUS_SERIES.map((s) => ({
|
||||||
name: s.label,
|
name: s.label,
|
||||||
@@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
|||||||
}));
|
}));
|
||||||
const hasStatus = statusData.some((d) => d.value > 0);
|
const hasStatus = statusData.some((d) => d.value > 0);
|
||||||
|
|
||||||
const trend = useMemo(
|
|
||||||
() => buildTrend(inventory ?? [], granularity),
|
|
||||||
[inventory, granularity],
|
|
||||||
);
|
|
||||||
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
<Stack gap="md">
|
||||||
{/* Time-filtered throughput */}
|
{/* Time-filtered throughput */}
|
||||||
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
|
<Card withBorder radius="lg" padding="lg">
|
||||||
<Group justify="space-between" mb="md" wrap="wrap">
|
<Group justify="space-between" mb="md" wrap="wrap">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||||
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
|||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
{/* Status distribution donut */}
|
|
||||||
<Card withBorder radius="lg" padding="lg">
|
|
||||||
<Group gap="sm" mb="md">
|
|
||||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
|
||||||
<PieChartIcon size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>Lifecycle Distribution</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Share of inventory across statuses
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{hasStatus ? (
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={statusData}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={55}
|
|
||||||
outerRadius={95}
|
|
||||||
paddingAngle={2}
|
|
||||||
>
|
|
||||||
{statusData.map((entry) => (
|
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<EmptyChart />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrendBucket {
|
|
||||||
label: string;
|
|
||||||
received: number;
|
|
||||||
dispatched: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
|
|
||||||
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
|
|
||||||
const now = new Date();
|
|
||||||
const buckets: { label: string; start: Date; end: Date }[] = [];
|
|
||||||
|
|
||||||
if (granularity === 'week') {
|
|
||||||
for (let i = 7; i >= 0; i--) {
|
|
||||||
const end = new Date(now);
|
|
||||||
end.setDate(now.getDate() - i * 7);
|
|
||||||
const start = new Date(end);
|
|
||||||
start.setDate(end.getDate() - 7);
|
|
||||||
buckets.push({ label: `W${8 - i}`, start, end });
|
|
||||||
}
|
|
||||||
} else if (granularity === 'month') {
|
|
||||||
for (let i = 11; i >= 0; i--) {
|
|
||||||
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
||||||
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
|
||||||
buckets.push({
|
|
||||||
label: start.toLocaleString('en', { month: 'short' }),
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 4; i >= 0; i--) {
|
|
||||||
const year = now.getFullYear() - i;
|
|
||||||
buckets.push({
|
|
||||||
label: String(year),
|
|
||||||
start: new Date(year, 0, 1),
|
|
||||||
end: new Date(year + 1, 0, 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
|
|
||||||
if (!iso) return false;
|
|
||||||
const t = new Date(iso).getTime();
|
|
||||||
return t >= start.getTime() && t < end.getTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
return buckets.map((b) => ({
|
|
||||||
label: b.label,
|
|
||||||
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
|
|
||||||
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyChart() {
|
function EmptyChart() {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" align="center" h={280}>
|
<Group justify="center" align="center" h={280}>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() {
|
|||||||
value: data?.receivedToday ?? 0,
|
value: data?.receivedToday ?? 0,
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
|
// Live signal: change vs yesterday's received count.
|
||||||
|
delta:
|
||||||
|
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||||
|
hint: "vs yesterday",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pending inspection",
|
label: "Pending inspection",
|
||||||
|
|||||||
@@ -32,3 +32,6 @@ export { FeePreviewModal } from './FeePreviewModal';
|
|||||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||||
export { AccrualDashboard } from './AccrualDashboard';
|
export { AccrualDashboard } from './AccrualDashboard';
|
||||||
|
export { DwellAgingCard } from './DwellAgingCard';
|
||||||
|
export { CycleTimeCard } from './CycleTimeCard';
|
||||||
|
export { GateThroughputCard } from './GateThroughputCard';
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export const formatCapacity = (current: number, capacity: number | null | undefi
|
|||||||
return `${cur} / ${formatNumber(capacity)}`;
|
return `${cur} / ${formatNumber(capacity)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A day count as a short, human duration: "0.2d" / "3.5 days" / "—". */
|
||||||
|
export const formatDays = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(Number(value))) return '—';
|
||||||
|
const num = Number(value);
|
||||||
|
const rounded = Math.round(num * 10) / 10;
|
||||||
|
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatDate = (value: string | null | undefined) => {
|
export const formatDate = (value: string | null | undefined) => {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const QUERY_KEYS = {
|
|||||||
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
||||||
orderList: (resource: RuleEngineResourceSlug | string) =>
|
orderList: (resource: RuleEngineResourceSlug | string) =>
|
||||||
["rule-engine", "order-list", resource] as const,
|
["rule-engine", "order-list", resource] as const,
|
||||||
|
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||||
|
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
|
||||||
|
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
|
||||||
|
`/contracts/${id}/document/articles`,
|
||||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||||
@@ -487,6 +490,11 @@ export const URL_CONSTANTS = {
|
|||||||
RESERVE: "/warehouse-inventory/reserve",
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||||
|
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
|
||||||
|
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||||
|
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
||||||
|
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
|
||||||
|
GATE_STATS: "/warehouse-inventory/gate-stats",
|
||||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||||
yardId
|
yardId
|
||||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||||
@@ -559,6 +567,7 @@ export const URL_CONSTANTS = {
|
|||||||
FEE_PREVIEW: (inventoryId: string) =>
|
FEE_PREVIEW: (inventoryId: string) =>
|
||||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||||
|
ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch",
|
||||||
ACCRUAL_ACK: (inventoryId: string) =>
|
ACCRUAL_ACK: (inventoryId: string) =>
|
||||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,12 +125,27 @@ export function useContractMutations(contractId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const staffAccept = useMutation({
|
const staffAccept = useMutation({
|
||||||
mutationFn: (validityDays: number) =>
|
mutationFn: (payload: {
|
||||||
contractsService.staffAccept(contractId, validityDays),
|
validityDays: number;
|
||||||
|
documentSnapshot?: Freight.IContractDocumentSnapshot;
|
||||||
|
}) =>
|
||||||
|
contractsService.staffAccept(
|
||||||
|
contractId,
|
||||||
|
payload.validityDays,
|
||||||
|
payload.documentSnapshot,
|
||||||
|
),
|
||||||
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
||||||
onError: () => toast.error("Failed to accept contract"),
|
onError: () => toast.error("Failed to accept contract"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit THIS contract's document articles (per-contract; never the templates).
|
||||||
|
const updateDocument = useMutation({
|
||||||
|
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
|
||||||
|
contractsService.updateContractDocument(contractId, snapshot),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Contract document updated"),
|
||||||
|
onError: () => toast.error("Failed to update contract document"),
|
||||||
|
});
|
||||||
|
|
||||||
const requestChanges = useMutation({
|
const requestChanges = useMutation({
|
||||||
mutationFn: (note: string) =>
|
mutationFn: (note: string) =>
|
||||||
contractsService.requestChanges(contractId, note),
|
contractsService.requestChanges(contractId, note),
|
||||||
@@ -144,11 +159,6 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: () => toast.error("Failed to reject contract"),
|
onError: () => toast.error("Failed to reject contract"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Statuses that mean every approval step is done and the contract is ready to
|
|
||||||
// be generated. Once the final approval lands we generate the PDF
|
|
||||||
// automatically — staff no longer click a separate "Generate" button.
|
|
||||||
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
|
|
||||||
|
|
||||||
const approveStep = useMutation({
|
const approveStep = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
stepId,
|
stepId,
|
||||||
@@ -158,25 +168,15 @@ export function useContractMutations(contractId: string) {
|
|||||||
requiredRole: string;
|
requiredRole: string;
|
||||||
}) =>
|
}) =>
|
||||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||||
onSuccess: async (data) => {
|
onSuccess: (data) => {
|
||||||
// If this was the LAST approval, auto-generate the contract so it goes
|
// The document is generated at the accept stage and reviewed during
|
||||||
// straight to CONTRACT_READY without a manual step.
|
// approval, so the final approval moves the contract straight to
|
||||||
const alreadyGenerated = Boolean(
|
// CONTRACT_READY on the server — no client-side generate call here.
|
||||||
(data as Freight.IContract).contractGeneratedAt,
|
const message =
|
||||||
);
|
data.status === "CONTRACT_READY"
|
||||||
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
|
? "Final approval complete — contract ready to sign"
|
||||||
toast.success("Final approval complete — generating contract…");
|
: "Approval step completed";
|
||||||
try {
|
onSuccess(data, message);
|
||||||
const generated = await contractsService.generateContract(data.id);
|
|
||||||
onSuccess(generated, "Contract generated and ready to sign");
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
toast.error("Approved, but contract generation failed. Retry below.");
|
|
||||||
void invalidateContractDetail(qc, data.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSuccess(data, "Approval step completed");
|
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to approve step"),
|
onError: () => toast.error("Failed to approve step"),
|
||||||
});
|
});
|
||||||
@@ -239,6 +239,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
|
|
||||||
const isPending =
|
const isPending =
|
||||||
staffAccept.isPending ||
|
staffAccept.isPending ||
|
||||||
|
updateDocument.isPending ||
|
||||||
requestChanges.isPending ||
|
requestChanges.isPending ||
|
||||||
reject.isPending ||
|
reject.isPending ||
|
||||||
approveStep.isPending ||
|
approveStep.isPending ||
|
||||||
@@ -249,6 +250,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
staffAccept,
|
staffAccept,
|
||||||
|
updateDocument,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
reject,
|
reject,
|
||||||
approveStep,
|
approveStep,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import {
|
||||||
|
ruleEngineService,
|
||||||
|
type RuleEngineListParams,
|
||||||
|
type SubmitPriorityRuleChangePayload,
|
||||||
|
} from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||||
import type {
|
import type {
|
||||||
RuleEngineRecord,
|
RuleEngineRecord,
|
||||||
@@ -239,6 +243,68 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
|||||||
return { create, update, remove };
|
return { create, update, remove };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority-rule approval workflow. Every create/update/delete of a priority
|
||||||
|
* config is SUBMITTED as a change request; an approver applies or rejects it.
|
||||||
|
* Error toasts surface the backend message so range-collision rejections
|
||||||
|
* ("1–5 overlaps existing rule …") reach the user verbatim.
|
||||||
|
*/
|
||||||
|
export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const backendMessage = (err: unknown, fallback: string) => {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(msg)) return msg.join(", ");
|
||||||
|
return msg || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||||
|
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await qc.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||||
|
});
|
||||||
|
await invalidateRuleEngineList(qc, "priority-configs");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: (payload: SubmitPriorityRuleChangePayload) =>
|
||||||
|
ruleEngineService.submitPriorityRuleChange(payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Change submitted for approval — the team has been notified");
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approve = useMutation({
|
||||||
|
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||||
|
ruleEngineService.approvePriorityRuleChange(id, decisionNote),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Change approved and applied");
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reject = useMutation({
|
||||||
|
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||||
|
ruleEngineService.rejectPriorityRuleChange(id, decisionNote),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Change rejected");
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pending, submit, approve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
export const useRateWorkflow = () => {
|
export const useRateWorkflow = () => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
||||||
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +150,55 @@ export function useWarehouseOpsStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
|
||||||
|
export const DASHBOARD_REFETCH_MS = 60_000;
|
||||||
|
|
||||||
|
/** Server-side received-vs-dispatched throughput time series. */
|
||||||
|
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'throughput', granularity],
|
||||||
|
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dwell time of in-warehouse items (average + aging buckets). */
|
||||||
|
export function useWarehouseDwellStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'dwell-stats'],
|
||||||
|
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average stage cycle times over recently dispatched items. */
|
||||||
|
export function useWarehouseCycleStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'cycle-stats'],
|
||||||
|
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gate / dock throughput (cleared today, turnaround, hourly clearances). */
|
||||||
|
export function useWarehouseGateStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'gate-stats'],
|
||||||
|
queryFn: () => warehouseService.gateStats().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-time dispatch rate (left before storage free-days expired). */
|
||||||
|
export function useOnTimeDispatch() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['warehouse-fees', 'on-time-dispatch'],
|
||||||
|
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +456,7 @@ export function useWarehouseDashboard() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['warehouses', 'dashboard'],
|
queryKey: ['warehouses', 'dashboard'],
|
||||||
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||||
|
refetchInterval: DASHBOARD_REFETCH_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -965,7 +965,7 @@ export function WagonsCrudPage() {
|
|||||||
{ value: 'EXPORT_READY', label: 'Export ready' },
|
{ value: 'EXPORT_READY', label: 'Export ready' },
|
||||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
{ value: 'RETIRED', label: 'Retired' },
|
{ value: 'DETAINED', label: 'Detained' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const WAGON_STATUS_OPTIONS = [
|
|||||||
{ label: "Available", value: Freight.WagonStatus.Available },
|
{ label: "Available", value: Freight.WagonStatus.Available },
|
||||||
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
||||||
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
|
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
|
||||||
{ label: "Retired", value: Freight.WagonStatus.Retired },
|
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import type { PriorityRuleChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
|
|
||||||
|
const ACTION_COLOR: Record<PriorityRuleChangeRequest["action"], string> = {
|
||||||
|
CREATE: "teal",
|
||||||
|
UPDATE: "blue",
|
||||||
|
DELETE: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDateTime = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "WAGON 1–5 · 30 pts" from a change payload / target rule. */
|
||||||
|
const ruleSummary = (
|
||||||
|
r: PriorityRuleChangeRequest,
|
||||||
|
): string => {
|
||||||
|
const source = (r.payload ?? r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||||
|
const base = (r.priorityConfig ?? {}) as Record<string, unknown>;
|
||||||
|
const pick = (key: string) => source[key] ?? base[key];
|
||||||
|
const type = pick("type");
|
||||||
|
const currency = pick("currency");
|
||||||
|
const min = pick("minWagonCount");
|
||||||
|
const max = pick("maxWagonCount");
|
||||||
|
const pts = pick("scorePoints");
|
||||||
|
const parts = [
|
||||||
|
type ? String(type) : null,
|
||||||
|
currency ? String(currency) : null,
|
||||||
|
min != null && max != null ? `${min}–${max} wagons` : null,
|
||||||
|
pts != null ? `${pts} pts` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(" · ") || "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Decide = UseMutationResult<
|
||||||
|
PriorityRuleChangeRequest,
|
||||||
|
unknown,
|
||||||
|
{ id: string; decisionNote?: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface PriorityRuleApprovalsSectionProps {
|
||||||
|
requests: PriorityRuleChangeRequest[];
|
||||||
|
canDecide: boolean;
|
||||||
|
approve: Decide;
|
||||||
|
reject: Decide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending priority-rule change requests awaiting approval. Rendered above the
|
||||||
|
* rules table on the priority-configs page; every rule change lands here first
|
||||||
|
* and only an approval applies it.
|
||||||
|
*/
|
||||||
|
const PriorityRuleApprovalsSection = ({
|
||||||
|
requests,
|
||||||
|
canDecide,
|
||||||
|
approve,
|
||||||
|
reject,
|
||||||
|
}: PriorityRuleApprovalsSectionProps) => {
|
||||||
|
if (requests.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="md" mb="md">
|
||||||
|
<Group gap={8} mb="sm">
|
||||||
|
<Text fw={700}>Pending approvals</Text>
|
||||||
|
<Badge variant="light" color="yellow">
|
||||||
|
{requests.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{requests.map((r) => (
|
||||||
|
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Badge variant="light" color={ACTION_COLOR[r.action]} radius="sm">
|
||||||
|
{r.action.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{ruleSummary(r)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Submitted {fmtDateTime(r.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{canDecide ? (
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
leftSection={<XCircle size={14} />}
|
||||||
|
loading={reject.isPending}
|
||||||
|
onClick={() => reject.mutate({ id: r.id })}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
loading={approve.isPending}
|
||||||
|
onClick={() => approve.mutate({ id: r.id })}
|
||||||
|
>
|
||||||
|
Approve & apply
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriorityRuleApprovalsSection;
|
||||||
@@ -18,6 +18,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||||
|
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
useContainerTypeOptions,
|
useContainerTypeOptions,
|
||||||
useLiveRateOptions,
|
useLiveRateOptions,
|
||||||
useWagonTypeOptions,
|
useWagonTypeOptions,
|
||||||
|
usePriorityRuleWorkflow,
|
||||||
useRateWorkflow,
|
useRateWorkflow,
|
||||||
useRuleEngineList,
|
useRuleEngineList,
|
||||||
useRuleEngineMutations,
|
useRuleEngineMutations,
|
||||||
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
chainOpen && config?.slug === "approval-rules",
|
chainOpen && config?.slug === "approval-rules",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Priority rules never mutate directly: changes are filed for approval and a
|
||||||
|
// pending queue renders above the table.
|
||||||
|
const isPriorityRules = config?.slug === "priority-configs";
|
||||||
|
const priorityWorkflow = usePriorityRuleWorkflow(
|
||||||
|
Boolean(isPriorityRules && canView),
|
||||||
|
);
|
||||||
|
|
||||||
const editingId = editing?.id ? String(editing.id) : undefined;
|
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||||
const usesContainerTypeField = Boolean(
|
const usesContainerTypeField = Boolean(
|
||||||
config?.formFields.some((f) => f.name === "containerTypeId"),
|
config?.formFields.some((f) => f.name === "containerTypeId"),
|
||||||
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
|
|||||||
currency: "USD",
|
currency: "USD",
|
||||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||||
};
|
};
|
||||||
} else if (config.slug === "priority-configs") {
|
} else if (isPriorityRules) {
|
||||||
// Label is required by the backend but hidden in the UI for now.
|
// Label is required by the backend but hidden in the UI for now.
|
||||||
payload = { ...values, label: String(Date.now()) };
|
payload = { ...values, label: String(Date.now()) };
|
||||||
|
// Approval workflow: file a change request instead of mutating directly.
|
||||||
|
// On update, keep the target's existing label rather than a fresh stamp.
|
||||||
|
if (editing?.id) {
|
||||||
|
priorityWorkflow.submit.mutate(
|
||||||
|
{
|
||||||
|
action: "UPDATE",
|
||||||
|
priorityConfigId: String(editing.id),
|
||||||
|
update: { ...values, label: String(editing.label ?? Date.now()) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
priorityWorkflow.submit.mutate(
|
||||||
|
{ action: "CREATE", create: payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (config.slug === "weight-limit-rules") {
|
} else if (config.slug === "weight-limit-rules") {
|
||||||
// Empty max capacity means "no ceiling" — send null explicitly so an
|
// Empty max capacity means "no ceiling" — send null explicitly so an
|
||||||
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
||||||
@@ -406,6 +443,15 @@ const RuleEngineResourcePage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isPriorityRules ? (
|
||||||
|
<PriorityRuleApprovalsSection
|
||||||
|
requests={priorityWorkflow.pending.data ?? []}
|
||||||
|
canDecide={canManage}
|
||||||
|
approve={priorityWorkflow.approve}
|
||||||
|
reject={priorityWorkflow.reject}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
|
|||||||
}
|
}
|
||||||
fields={formFields}
|
fields={formFields}
|
||||||
initialRecord={editing}
|
initialRecord={editing}
|
||||||
isSubmitting={create.isPending || update.isPending}
|
isSubmitting={
|
||||||
|
create.isPending || update.isPending || priorityWorkflow.submit.isPending
|
||||||
|
}
|
||||||
selectOptionsLoading={
|
selectOptionsLoading={
|
||||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
This will soft-delete the selected {config.label.toLowerCase()}{" "}
|
{isPriorityRules
|
||||||
record.
|
? "This files a delete request for approval — the rule is removed once an approver confirms."
|
||||||
|
: `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||||
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
loading={remove.isPending}
|
loading={remove.isPending || priorityWorkflow.submit.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
|
if (isPriorityRules) {
|
||||||
|
priorityWorkflow.submit.mutate(
|
||||||
|
{
|
||||||
|
action: "DELETE",
|
||||||
|
priorityConfigId: String(deleteTarget.id),
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setDeleteTarget(null) },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
remove.mutate(deleteTarget.id, {
|
remove.mutate(deleteTarget.id, {
|
||||||
onSuccess: () => setDeleteTarget(null),
|
onSuccess: () => setDeleteTarget(null),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
{isPriorityRules ? "Request delete" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
ScheduleWarningsAlert,
|
ScheduleWarningsAlert,
|
||||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
|
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
|
||||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
@@ -252,12 +253,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
|
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
|
||||||
[displayWagonPlan, isExportDisplay],
|
[displayWagonPlan, isExportDisplay],
|
||||||
);
|
);
|
||||||
const diagramWagons = useMemo(() => {
|
|
||||||
const source = schedule?.trainSet?.wagons?.length
|
|
||||||
? schedule.trainSet.wagons
|
|
||||||
: displayWagonPlan;
|
|
||||||
return isExportDisplay ? [...source].reverse() : source;
|
|
||||||
}, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
|
|
||||||
|
|
||||||
const runPreview = useCallback(
|
const runPreview = useCallback(
|
||||||
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
||||||
@@ -774,22 +769,26 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalize
|
// finalize — the train is known here, so draw the full composition the
|
||||||
|
// same way the batch board's composition tab does (interactive consist).
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TrainCompositionDiagram
|
{schedule.trainSet ? (
|
||||||
locomotive={schedule.trainSet?.locomotive}
|
<TrainConsistView
|
||||||
locomotives={locomotives}
|
scheduleDetail={schedule}
|
||||||
wagons={diagramWagons}
|
scheduleId={scheduleId ?? ""}
|
||||||
freightType={freightType}
|
maxWagons={schedule.maxWagons ?? 53}
|
||||||
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
/>
|
||||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
) : (
|
||||||
/>
|
<TrainCompositionDiagram
|
||||||
{isExportDisplay && diagramWagons.length ? (
|
locomotive={null}
|
||||||
<Text size="xs" c="dimmed">
|
locomotives={locomotives}
|
||||||
Shown rear-first (export direction) — positions keep their original numbers.
|
wagons={[]}
|
||||||
</Text>
|
freightType={freightType}
|
||||||
) : null}
|
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
||||||
|
totalLengthMeters={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Paper
|
<Paper
|
||||||
p="lg"
|
p="lg"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -16,10 +16,26 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
import {
|
||||||
|
AccrualDashboard,
|
||||||
|
CycleTimeCard,
|
||||||
|
DwellAgingCard,
|
||||||
|
GateThroughputCard,
|
||||||
|
WarehouseDashboardCharts,
|
||||||
|
WarehouseOpsKpiStrip,
|
||||||
|
ZoneOccupancyHeatmap,
|
||||||
|
} from '@/components/warehouses';
|
||||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Metric {
|
interface Metric {
|
||||||
key: keyof WarehouseDashboard;
|
key: keyof WarehouseDashboard;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -56,6 +72,26 @@ export default function WarehouseDashboardPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Warehouse Dashboard"
|
title="Warehouse Dashboard"
|
||||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||||
|
action={
|
||||||
|
<Badge
|
||||||
|
color="edr-green"
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
leftSection={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--mantine-color-edr-green-6)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Live · updates every 60s
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -67,7 +103,16 @@ export default function WarehouseDashboardPage() {
|
|||||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Stack gap="xl">
|
||||||
|
{/* Needs attention — live ops counters (received today, pending
|
||||||
|
inspection, trucks on-site, items aging > 7 days). */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Needs attention</SectionTitle>
|
||||||
|
<WarehouseOpsKpiStrip />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||||
{METRICS.map((metric) => (
|
{METRICS.map((metric) => (
|
||||||
<Card
|
<Card
|
||||||
@@ -98,8 +143,30 @@ export default function WarehouseDashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<WarehouseDashboardCharts data={data} />
|
<Stack gap="sm">
|
||||||
</>
|
<SectionTitle>Flow</SectionTitle>
|
||||||
|
<WarehouseDashboardCharts data={data} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Performance</SectionTitle>
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2, xl: 3 }} spacing="md">
|
||||||
|
<DwellAgingCard />
|
||||||
|
<CycleTimeCard />
|
||||||
|
<GateThroughputCard />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Zone capacity</SectionTitle>
|
||||||
|
<ZoneOccupancyHeatmap />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionTitle>Demurrage & storage exceptions</SectionTitle>
|
||||||
|
<AccrualDashboard />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ import {
|
|||||||
type WagonMovementRecord,
|
type WagonMovementRecord,
|
||||||
type WagonTransferRequest,
|
type WagonTransferRequest,
|
||||||
type CreateTransferRequestPayload,
|
type CreateTransferRequestPayload,
|
||||||
|
type BulkFulfillResult,
|
||||||
type TransferHistory,
|
type TransferHistory,
|
||||||
} from "./wagon.service";
|
} from "./wagon.service";
|
||||||
import { warehouseService } from "./warehouse.service";
|
import { warehouseService } from "./warehouse.service";
|
||||||
@@ -1721,6 +1722,15 @@ export const api = {
|
|||||||
() => [["wagonTransferRequests"], ["wagons"]],
|
() => [["wagonTransferRequests"], ["wagons"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
bulkFulfill: endpoint<{ requestIds: string[] }, BulkFulfillResult>(
|
||||||
|
"wagonTransferRequests",
|
||||||
|
"bulkFulfill",
|
||||||
|
({ requestIds }) =>
|
||||||
|
wagonTransferRequestService.bulkFulfill(requestIds).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["wagonTransferRequests"], ["wagons"]],
|
||||||
|
),
|
||||||
|
|
||||||
cancel: endpoint<{ id: string }, WagonTransferRequest>(
|
cancel: endpoint<{ id: string }, WagonTransferRequest>(
|
||||||
"wagonTransferRequests",
|
"wagonTransferRequests",
|
||||||
"cancel",
|
"cancel",
|
||||||
|
|||||||
@@ -170,8 +170,35 @@ export const contractsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Staff review ──
|
// ── Staff review ──
|
||||||
staffAccept: (id: string, validityDays: number) =>
|
staffAccept: (
|
||||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
|
id: string,
|
||||||
|
validityDays: number,
|
||||||
|
documentSnapshot?: Freight.IContractDocumentSnapshot,
|
||||||
|
) =>
|
||||||
|
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
|
||||||
|
validityDays,
|
||||||
|
documentSnapshot,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** The editable per-contract document draft (snapshot or live template). */
|
||||||
|
getContractDocumentDraft: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<Freight.IContractDocumentDraft> => {
|
||||||
|
const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id));
|
||||||
|
return unwrap(response.data) as Freight.IContractDocumentDraft;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Save this contract's edited document articles (never touches the templates). */
|
||||||
|
updateContractDocument: async (
|
||||||
|
id: string,
|
||||||
|
snapshot: Freight.IContractDocumentSnapshot,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const response = await client.put(
|
||||||
|
C.CONTRACT_DOCUMENT_ARTICLES(id),
|
||||||
|
snapshot,
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
requestChanges: (id: string, note: string) =>
|
requestChanges: (id: string, note: string) =>
|
||||||
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ export interface RuleEngineReorderPayload {
|
|||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Priority-rule approval workflow (all priority-config changes go through it). */
|
||||||
|
const PRIORITY_RULE_CHANGES_BASE = "/priority-rule-change-requests";
|
||||||
|
|
||||||
|
export interface PriorityRuleChangeRequest {
|
||||||
|
id: string;
|
||||||
|
action: "CREATE" | "UPDATE" | "DELETE";
|
||||||
|
priorityConfigId: string | null;
|
||||||
|
priorityConfig?: RuleEngineRecord | null;
|
||||||
|
payload: Record<string, unknown> | null;
|
||||||
|
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||||
|
requestedByUserId: string | null;
|
||||||
|
decidedByUserId: string | null;
|
||||||
|
decidedAt: string | null;
|
||||||
|
decisionNote: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitPriorityRuleChangePayload {
|
||||||
|
action: "CREATE" | "UPDATE" | "DELETE";
|
||||||
|
priorityConfigId?: string;
|
||||||
|
create?: Record<string, unknown>;
|
||||||
|
update?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||||
@@ -242,6 +266,46 @@ export const ruleEngineService = {
|
|||||||
return normalizeEntity<T>(response.data);
|
return normalizeEntity<T>(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** File a priority-rule change (create/update/delete) for approval. */
|
||||||
|
submitPriorityRuleChange: async (
|
||||||
|
payload: SubmitPriorityRuleChangePayload,
|
||||||
|
): Promise<PriorityRuleChangeRequest> => {
|
||||||
|
const response = await client.post(PRIORITY_RULE_CHANGES_BASE, payload);
|
||||||
|
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||||
|
},
|
||||||
|
|
||||||
|
listPriorityRuleChanges: async (
|
||||||
|
status?: PriorityRuleChangeRequest["status"],
|
||||||
|
): Promise<PriorityRuleChangeRequest[]> => {
|
||||||
|
const response = await client.get(PRIORITY_RULE_CHANGES_BASE, {
|
||||||
|
params: status ? { status } : undefined,
|
||||||
|
});
|
||||||
|
const body = unwrap(response.data) as unknown;
|
||||||
|
return Array.isArray(body) ? (body as PriorityRuleChangeRequest[]) : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
approvePriorityRuleChange: async (
|
||||||
|
id: string,
|
||||||
|
decisionNote?: string,
|
||||||
|
): Promise<PriorityRuleChangeRequest> => {
|
||||||
|
const response = await client.post(
|
||||||
|
`${PRIORITY_RULE_CHANGES_BASE}/${id}/approve`,
|
||||||
|
{ decisionNote },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectPriorityRuleChange: async (
|
||||||
|
id: string,
|
||||||
|
decisionNote?: string,
|
||||||
|
): Promise<PriorityRuleChangeRequest> => {
|
||||||
|
const response = await client.post(
|
||||||
|
`${PRIORITY_RULE_CHANGES_BASE}/${id}/reject`,
|
||||||
|
{ decisionNote },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||||
|
},
|
||||||
|
|
||||||
getApprovalChain: async (
|
getApprovalChain: async (
|
||||||
requiresDirectorApproval = true,
|
requiresDirectorApproval = true,
|
||||||
): Promise<RuleEngineRecord[]> => {
|
): Promise<RuleEngineRecord[]> => {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface WagonTransferRequest {
|
|||||||
requestedByUserId: string | null;
|
requestedByUserId: string | null;
|
||||||
fulfilledByUserId: string | null;
|
fulfilledByUserId: string | null;
|
||||||
fulfilledAt: string | null;
|
fulfilledAt: string | null;
|
||||||
|
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||||
|
reason?: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
fromYard?: { id: string; label?: string; code?: string } | null;
|
fromYard?: { id: string; label?: string; code?: string } | null;
|
||||||
toYard?: { id: string; label?: string; code?: string } | null;
|
toYard?: { id: string; label?: string; code?: string } | null;
|
||||||
@@ -120,9 +122,17 @@ export interface CreateTransferRequestPayload {
|
|||||||
toYardId: string;
|
toYardId: string;
|
||||||
wagonTypeId: string;
|
wagonTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
/** Mandatory: why the wagons are needed. */
|
||||||
|
reason: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
|
||||||
|
export interface BulkFulfillResult {
|
||||||
|
fulfilled: WagonTransferRequest[];
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||||
export interface TransferHistory {
|
export interface TransferHistory {
|
||||||
requests: WagonTransferRequest[];
|
requests: WagonTransferRequest[];
|
||||||
@@ -146,6 +156,11 @@ export const wagonTransferRequestService = {
|
|||||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||||
create: (data: CreateTransferRequestPayload) =>
|
create: (data: CreateTransferRequestPayload) =>
|
||||||
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
|
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
|
||||||
|
/** OCC: accept-and-execute a subset of pending requests (auto-picked wagons). */
|
||||||
|
bulkFulfill: (requestIds: string[]) =>
|
||||||
|
apiClient.post<BulkFulfillResult>('/wagon-transfer-requests/bulk-fulfill', {
|
||||||
|
requestIds,
|
||||||
|
}),
|
||||||
/** OCC: execute the transfer with the hand-picked wagons. */
|
/** OCC: execute the transfer with the hand-picked wagons. */
|
||||||
fulfill: (id: string, wagonIds: string[]) =>
|
fulfill: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<WagonTransferRequest>(
|
apiClient.post<WagonTransferRequest>(
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
|||||||
import type {
|
import type {
|
||||||
ZoneOccupancy,
|
ZoneOccupancy,
|
||||||
WarehouseOpsStats,
|
WarehouseOpsStats,
|
||||||
|
WarehouseThroughputPoint,
|
||||||
|
WarehouseDwellStats,
|
||||||
|
WarehouseCycleStats,
|
||||||
|
WarehouseOnTimeStats,
|
||||||
|
WarehouseGateStats,
|
||||||
AccrualDashboardRow,
|
AccrualDashboardRow,
|
||||||
AllocationCriteria,
|
AllocationCriteria,
|
||||||
AllocationPreviewResult,
|
AllocationPreviewResult,
|
||||||
@@ -394,6 +399,16 @@ export const warehouseService = {
|
|||||||
),
|
),
|
||||||
opsStats: () =>
|
opsStats: () =>
|
||||||
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||||
|
throughput: (granularity: 'week' | 'month' | 'year') =>
|
||||||
|
apiClient.get<WarehouseThroughputPoint[]>(
|
||||||
|
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
|
||||||
|
),
|
||||||
|
dwellStats: () =>
|
||||||
|
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
|
||||||
|
cycleStats: () =>
|
||||||
|
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
|
||||||
|
gateStats: () =>
|
||||||
|
apiClient.get<WarehouseGateStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
|
||||||
autoUnloadArrived: () =>
|
autoUnloadArrived: () =>
|
||||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||||
autoLoadReady: () =>
|
autoLoadReady: () =>
|
||||||
@@ -450,6 +465,8 @@ export const warehouseService = {
|
|||||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||||
params: cleanParams({ billingCurrency }),
|
params: cleanParams({ billingCurrency }),
|
||||||
}),
|
}),
|
||||||
|
onTimeDispatch: () =>
|
||||||
|
apiClient.get<WarehouseOnTimeStats>(URL_CONSTANTS.WAREHOUSE_RULES.ON_TIME_DISPATCH),
|
||||||
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
|
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
|
||||||
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
|
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
|
||||||
unacknowledgeAccrual: (inventoryId: string) =>
|
unacknowledgeAccrual: (inventoryId: string) =>
|
||||||
|
|||||||
@@ -528,6 +528,8 @@ export interface TrainScheduleDetail {
|
|||||||
deferredBookings?: DeferredBookingRow[];
|
deferredBookings?: DeferredBookingRow[];
|
||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
|
/** Wagon cap for this departure (built-train consist size or configured limit). */
|
||||||
|
maxWagons?: number | null;
|
||||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
train?: {
|
train?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1115,11 +1115,47 @@ export interface ZoneOccupancy {
|
|||||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||||
export interface WarehouseOpsStats {
|
export interface WarehouseOpsStats {
|
||||||
receivedToday: number;
|
receivedToday: number;
|
||||||
|
receivedYesterday: number;
|
||||||
pendingInspection: number;
|
pendingInspection: number;
|
||||||
trucksOnSite: number;
|
trucksOnSite: number;
|
||||||
itemsAging: number;
|
itemsAging: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bucket of the received-vs-dispatched throughput time series. */
|
||||||
|
export interface WarehouseThroughputPoint {
|
||||||
|
periodStart: string;
|
||||||
|
received: number;
|
||||||
|
dispatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dwell time of in-warehouse items: average days + aging-bucket counts. */
|
||||||
|
export interface WarehouseDwellStats {
|
||||||
|
avgDwellDays: number;
|
||||||
|
inWarehouseCount: number;
|
||||||
|
buckets: Array<{ key: string; label: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average stage cycle times over recently dispatched items. */
|
||||||
|
export interface WarehouseCycleStats {
|
||||||
|
sampleSize: number;
|
||||||
|
avgDockToDispatchDays: number;
|
||||||
|
stages: Array<{ key: string; label: string; avgDays: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-time dispatch rate (items that left before storage free-days expired). */
|
||||||
|
export interface WarehouseOnTimeStats {
|
||||||
|
sampleSize: number;
|
||||||
|
onTimeCount: number;
|
||||||
|
onTimePct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gate / dock throughput: cleared today, turnaround, and hourly clearances. */
|
||||||
|
export interface WarehouseGateStats {
|
||||||
|
clearedToday: number;
|
||||||
|
avgTurnaroundHours: number | null;
|
||||||
|
byHour: Array<{ hour: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||||
|
|
||||||
/** One item's live fee accrual for the accrual dashboard. */
|
/** One item's live fee accrual for the accrual dashboard. */
|
||||||
|
|||||||
@@ -279,6 +279,60 @@ export default function NewBookingPage() {
|
|||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const operationType = form.watch("operationType");
|
const operationType = form.watch("operationType");
|
||||||
|
const watchedCargoKind = form.watch("cargoType");
|
||||||
|
const watchedContainers = form.watch("containers");
|
||||||
|
const watchedCargoTypePath = form.watch("cargoTypePath");
|
||||||
|
const watchedScheduledDate = form.watch("scheduledDate");
|
||||||
|
const isGeneralContractBooking =
|
||||||
|
form.watch("bookingType") === "general_contract";
|
||||||
|
|
||||||
|
// Wagon-TYPE availability gate: which days have a departure whose train can
|
||||||
|
// physically carry the selected cargo/container type. Quantity is NOT part
|
||||||
|
// of this gate — an oversized booking is accepted and gets a partial split
|
||||||
|
// offer later. Only selectable days reach the UI; no capacity counts.
|
||||||
|
const gateContainerTypeIds = useMemo(() => {
|
||||||
|
if (watchedCargoKind !== "container") return [];
|
||||||
|
const groups = referenceData?.containers ?? [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const line of watchedContainers ?? []) {
|
||||||
|
if (!line?.containerType) continue;
|
||||||
|
for (const group of groups) {
|
||||||
|
const ct = group.types.find((t) => t.name === line.containerType);
|
||||||
|
if (ct) ids.add(ct.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}, [watchedCargoKind, watchedContainers, referenceData]);
|
||||||
|
const gateCargoTypeId =
|
||||||
|
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
|
||||||
|
const gateReady =
|
||||||
|
!isGeneralContractBooking &&
|
||||||
|
!!originYard &&
|
||||||
|
!!destinationYard &&
|
||||||
|
(watchedCargoKind === "bulk"
|
||||||
|
? !!gateCargoTypeId
|
||||||
|
: gateContainerTypeIds.length > 0);
|
||||||
|
const availableDaysQuery = useQuery(
|
||||||
|
api.bookings.getAvailableDaysForCargo.queryOptions({
|
||||||
|
input: {
|
||||||
|
originYardId: originYard,
|
||||||
|
destinationYardId: destinationYard,
|
||||||
|
freightType: watchedCargoKind === "bulk" ? "BULK" : "CONTAINER",
|
||||||
|
cargoTypeId: gateCargoTypeId || undefined,
|
||||||
|
containerTypeIds: gateContainerTypeIds,
|
||||||
|
},
|
||||||
|
enabled: gateReady,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const availableBookingDays = gateReady ? availableDaysQuery.data : undefined;
|
||||||
|
// Block submit only on a POSITIVE answer that the picked day has no wagon
|
||||||
|
// for this cargo — a loading/failed availability lookup never bricks the
|
||||||
|
// wizard (the backend re-checks at the binding step anyway).
|
||||||
|
const noWagonForSelectedDay = Boolean(
|
||||||
|
availableBookingDays &&
|
||||||
|
watchedScheduledDate &&
|
||||||
|
!availableBookingDays.includes(watchedScheduledDate),
|
||||||
|
);
|
||||||
|
|
||||||
// The estimated shipment date lives in the Route step now; for general
|
// The estimated shipment date lives in the Route step now; for general
|
||||||
// contracts that date field is simply hidden there (the date is chosen per
|
// contracts that date field is simply hidden there (the date is chosen per
|
||||||
@@ -678,6 +732,8 @@ export default function NewBookingPage() {
|
|||||||
form={form}
|
form={form}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
isLoading={refDataLoading}
|
isLoading={refDataLoading}
|
||||||
|
availableDays={availableBookingDays}
|
||||||
|
noWagonForSelectedDay={noWagonForSelectedDay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 6 && <StepDocuments form={form} />}
|
{step === 6 && <StepDocuments form={form} />}
|
||||||
@@ -698,6 +754,7 @@ export default function NewBookingPage() {
|
|||||||
persistAndPriceMutation.isPending &&
|
persistAndPriceMutation.isPending &&
|
||||||
persistAndPriceMutation.variables?.mode === "submit"
|
persistAndPriceMutation.variables?.mode === "submit"
|
||||||
}
|
}
|
||||||
|
noWagonForSelectedDay={noWagonForSelectedDay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -746,6 +803,7 @@ export default function NewBookingPage() {
|
|||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
onClick={handleSubmitBooking}
|
onClick={handleSubmitBooking}
|
||||||
loading={isPricing}
|
loading={isPricing}
|
||||||
|
disabled={noWagonForSelectedDay}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -220,12 +220,14 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
Choose your shipment day
|
Choose your shipment day
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="12px" c="dimmed" mb="sm">
|
<Text fz="12px" c="dimmed" mb="sm">
|
||||||
Only days with a scheduled departure on your route can be selected.
|
Only days with a scheduled departure that can carry your cargo type
|
||||||
The operations team assigns the specific train for that day.
|
can be selected. The operations team assigns the specific train for
|
||||||
|
that day.
|
||||||
</Text>
|
</Text>
|
||||||
<OperationDatePicker
|
<OperationDatePicker
|
||||||
originYardId={booking.originYard?.id}
|
originYardId={booking.originYard?.id}
|
||||||
destinationYardId={booking.destinationYard?.id}
|
destinationYardId={booking.destinationYard?.id}
|
||||||
|
bookingId={booking.id}
|
||||||
value={scheduledDate}
|
value={scheduledDate}
|
||||||
onChange={setScheduledDate}
|
onChange={setScheduledDate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { api } from "@/services/api";
|
|||||||
interface OperationDatePickerProps {
|
interface OperationDatePickerProps {
|
||||||
originYardId?: string;
|
originYardId?: string;
|
||||||
destinationYardId?: string;
|
destinationYardId?: string;
|
||||||
|
/** When set, days are cargo-aware: only days whose train can carry THIS booking's cargo type. */
|
||||||
|
bookingId?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (date: string) => void;
|
onChange: (date: string) => void;
|
||||||
}
|
}
|
||||||
@@ -13,20 +15,31 @@ interface OperationDatePickerProps {
|
|||||||
/**
|
/**
|
||||||
* Route-based day picker for the operation-request step: a thin query wrapper
|
* Route-based day picker for the operation-request step: a thin query wrapper
|
||||||
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
|
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
|
||||||
* Only days with an OPEN scheduled departure on the route are selectable.
|
* Only days with an OPEN scheduled departure on the route are selectable; with
|
||||||
|
* a `bookingId` the server additionally drops days whose trains have no wagon
|
||||||
|
* type that can carry the booking's cargo (no capacity counts are shown).
|
||||||
*/
|
*/
|
||||||
export function OperationDatePicker({
|
export function OperationDatePicker({
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
|
bookingId,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: OperationDatePickerProps) {
|
}: OperationDatePickerProps) {
|
||||||
const { data: availableDays, isLoading } = useQuery(
|
const routeDays = useQuery(
|
||||||
api.bookings.getAvailableDays.queryOptions({
|
api.bookings.getAvailableDays.queryOptions({
|
||||||
input: { originYardId, destinationYardId },
|
input: { originYardId, destinationYardId },
|
||||||
enabled: !!originYardId && !!destinationYardId,
|
enabled: !bookingId && !!originYardId && !!destinationYardId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const bookingDays = useQuery(
|
||||||
|
api.bookings.getAvailableDaysForBooking.queryOptions({
|
||||||
|
input: { bookingId: bookingId ?? "" },
|
||||||
|
enabled: !!bookingId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const availableDays = bookingId ? bookingDays.data : routeDays.data;
|
||||||
|
const isLoading = bookingId ? bookingDays.isLoading : routeDays.isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { Box, Skeleton, Stack } from "@mantine/core";
|
import { Box, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
|
import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -21,10 +21,19 @@ export function Step4Route({
|
|||||||
form,
|
form,
|
||||||
referenceData,
|
referenceData,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
availableDays,
|
||||||
|
noWagonForSelectedDay,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
referenceData?: Freight.BookingReferenceData;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Days with a departure whose train can carry the selected cargo type
|
||||||
|
* (wagon-TYPE gate). `undefined` = unknown (loading/failed) — no gating.
|
||||||
|
* Never carries capacity counts.
|
||||||
|
*/
|
||||||
|
availableDays?: string[];
|
||||||
|
noWagonForSelectedDay?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
@@ -194,6 +203,16 @@ export function Step4Route({
|
|||||||
minDate={todayISODate}
|
minDate={todayISODate}
|
||||||
leftSection={<CalendarDays size={16} />}
|
leftSection={<CalendarDays size={16} />}
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
|
// Days whose trains cannot carry the selected cargo type are
|
||||||
|
// not selectable (wagon-TYPE gate; quantity never blocks).
|
||||||
|
excludeDate={(date) => {
|
||||||
|
if (!availableDays) return false;
|
||||||
|
const day =
|
||||||
|
typeof date === "string"
|
||||||
|
? date.slice(0, 10)
|
||||||
|
: new Date(date).toISOString().slice(0, 10);
|
||||||
|
return !availableDays.includes(day);
|
||||||
|
}}
|
||||||
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
|
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
|
||||||
// matching the form's `scheduledDate` string directly.
|
// matching the form's `scheduledDate` string directly.
|
||||||
value={field.value || null}
|
value={field.value || null}
|
||||||
@@ -204,6 +223,19 @@ export function Step4Route({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{noWagonForSelectedDay && (
|
||||||
|
<Text size="xs" c="red.7" mt={6}>
|
||||||
|
No wagon on this day's train can carry your cargo type —
|
||||||
|
please pick another available day.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{availableDays && availableDays.length === 0 && (
|
||||||
|
<Text size="xs" c="red.7" mt={6}>
|
||||||
|
No upcoming departure can carry this cargo type on the chosen
|
||||||
|
route right now. Try a different cargo/container type or check
|
||||||
|
back later.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function Step8Review({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
saveDraftPending = false,
|
saveDraftPending = false,
|
||||||
submitPending = false,
|
submitPending = false,
|
||||||
|
noWagonForSelectedDay = false,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
@@ -147,6 +148,8 @@ export function Step8Review({
|
|||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
saveDraftPending?: boolean;
|
saveDraftPending?: boolean;
|
||||||
submitPending?: boolean;
|
submitPending?: boolean;
|
||||||
|
/** Wagon-TYPE gate: the picked day has no train that can carry this cargo. */
|
||||||
|
noWagonForSelectedDay?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const serviceType = referenceData?.service.find(
|
const serviceType = referenceData?.service.find(
|
||||||
@@ -551,6 +554,19 @@ export function Step8Review({
|
|||||||
</p>
|
</p>
|
||||||
</AlertBox>
|
</AlertBox>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : noWagonForSelectedDay ? (
|
||||||
|
<Box mb="md">
|
||||||
|
<AlertBox tone="error">
|
||||||
|
<p className="font-semibold">
|
||||||
|
No wagon available for the selected day
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
No train departing that day has a wagon type that can carry
|
||||||
|
your cargo. Go back to the route step and pick one of the
|
||||||
|
available days.
|
||||||
|
</p>
|
||||||
|
</AlertBox>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
Ready to submit. You'll review the unit rates before final
|
Ready to submit. You'll review the unit rates before final
|
||||||
@@ -567,7 +583,7 @@ export function Step8Review({
|
|||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
loading={submitPending}
|
loading={submitPending}
|
||||||
disabled={submitPending || hasOdd20ft}
|
disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -423,6 +423,12 @@ export const api = {
|
|||||||
bookingsService.getAvailableDaysForCargo(input),
|
bookingsService.getAvailableDaysForCargo(input),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getAvailableDaysForBooking: endpoint<{ bookingId: string }, string[]>(
|
||||||
|
"train-scheduling",
|
||||||
|
"availableDaysForBooking",
|
||||||
|
({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
|
||||||
|
),
|
||||||
|
|
||||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"myBookingWindows",
|
"myBookingWindows",
|
||||||
|
|||||||
@@ -414,25 +414,38 @@ export const bookingsService = {
|
|||||||
return (data.data as Freight.AvailableDaysResponse).days;
|
return (data.data as Freight.AvailableDaysResponse).days;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cargo-aware day pool: only days where a train has remaining capacity AND
|
// Cargo-aware day pool: only days where a train has remaining capacity AND a
|
||||||
// enough matching-type wagons for this cargo. `containers` is serialized as a
|
// wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
|
||||||
// JSON string param (the server parses it).
|
// serialized as JSON string params (the server parses them). Days only — no
|
||||||
|
// capacity counts are ever returned.
|
||||||
getAvailableDaysForCargo: async (
|
getAvailableDaysForCargo: async (
|
||||||
query: Freight.AvailableDaysForCargoQuery,
|
query: Freight.AvailableDaysForCargoQuery,
|
||||||
): Promise<string[]> => {
|
): Promise<string[]> => {
|
||||||
const { containers, ...rest } = query;
|
const { containers, containerTypeIds, ...rest } = query;
|
||||||
const { data } = await client.get(
|
const { data } = await client.get(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
|
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...rest,
|
...rest,
|
||||||
...(containers ? { containers: JSON.stringify(containers) } : {}),
|
...(containers ? { containers: JSON.stringify(containers) } : {}),
|
||||||
|
...(containerTypeIds?.length
|
||||||
|
? { containerTypeIds: JSON.stringify(containerTypeIds) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return (data.data as Freight.AvailableDaysResponse).days;
|
return (data.data as Freight.AvailableDaysResponse).days;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Days bookable for an EXISTING booking (operation-request step): the server
|
||||||
|
// derives the cargo from the booking and applies the wagon-type gate.
|
||||||
|
getAvailableDaysForBooking: async (bookingId: string): Promise<string[]> => {
|
||||||
|
const { data } = await client.get(
|
||||||
|
`/api/bookings/${bookingId}/available-days`,
|
||||||
|
);
|
||||||
|
return (data.data as Freight.AvailableDaysResponse).days;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upcoming/open booking windows on the signed-in customer's active-contract
|
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||||
|
|||||||
@@ -280,7 +280,15 @@ export class SeatsService {
|
|||||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
|
// Only the raw BLOCKED status (seat pulled out of service — a genuine
|
||||||
|
// cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
|
||||||
|
// against this raw column: the same physical Seat row is reused across every
|
||||||
|
// recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
|
||||||
|
// trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
|
||||||
|
// here would wrongly block a seat that's actually free for this schedule/leg.
|
||||||
|
// The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
|
||||||
|
// authoritative source for whether a seat is actually taken.
|
||||||
|
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||||
if (blocked.length > 0)
|
if (blocked.length > 0)
|
||||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||||
|
|
||||||
|
|||||||
@@ -1065,23 +1065,36 @@ export default function ResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOneWayNoOutbound) {
|
if (isOneWayNoOutbound) {
|
||||||
const hasAlternatives = alternativeOutbound.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
{renderClassModal()}
|
{renderClassModal()}
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8">
|
<div className="card max-w-lg mx-auto text-center py-10 px-6 mb-8">
|
||||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||||
<span>No trains available on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}</span>.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
Change date
|
No trains available
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
There are no trains scheduled on{" "}
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
|
||||||
|
</span>
|
||||||
|
. Try a different date to see available trains.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(buildSearchUrl())}
|
||||||
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Change Date
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Alternative Travel Options — commented out for the time being;
|
||||||
|
only the "No trains available" banner above is shown.
|
||||||
{hasAlternatives && (
|
{hasAlternatives && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -1100,6 +1113,7 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1233,30 +1247,32 @@ export default function ResultsPage() {
|
|||||||
renderScheduleCard(schedule, true),
|
renderScheduleCard(schedule, true),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{outboundSchedules.length === 0 &&
|
{outboundSchedules.length === 0 && (
|
||||||
alternativeOutbound.length > 0 && (
|
<div className="mt-6">
|
||||||
<div className="mt-6">
|
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
|
||||||
Change dates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
|
||||||
Alternative Outbound Options
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{alternativeOutbound.map((schedule: Schedule) =>
|
|
||||||
renderScheduleCard(schedule, true, true),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
|
Change dates
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Alternative Outbound Options — commented out for the time being;
|
||||||
|
only the "No trains" banner above is shown.
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||||
|
Alternative Outbound Options
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alternativeOutbound.map((schedule: Schedule) =>
|
||||||
|
renderScheduleCard(schedule, true, true),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -1317,30 +1333,32 @@ export default function ResultsPage() {
|
|||||||
renderScheduleCard(schedule, false),
|
renderScheduleCard(schedule, false),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{inboundSchedules.length === 0 &&
|
{inboundSchedules.length === 0 && (
|
||||||
alternativeInbound.length > 0 && (
|
<div className="mt-6">
|
||||||
<div className="mt-6">
|
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
|
||||||
Change dates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
|
||||||
Alternative Return Options
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{alternativeInbound.map((schedule: Schedule) =>
|
|
||||||
renderScheduleCard(schedule, false, true),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
|
Change dates
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Alternative Return Options — commented out for the time being;
|
||||||
|
only the "No trains" banner above is shown.
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||||
|
Alternative Return Options
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alternativeInbound.map((schedule: Schedule) =>
|
||||||
|
renderScheduleCard(schedule, false, true),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -182,6 +182,42 @@ export interface IContractSignature {
|
|||||||
signedAt: string;
|
signedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-contract document snapshot (staff-editable articles for one contract) ─
|
||||||
|
|
||||||
|
export interface IContractDocumentArticle {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-contract frozen copy of the resolved document template. Staff may edit
|
||||||
|
* its articles for a single contract in the accept/edit dialog — this never
|
||||||
|
* writes back to the shared six contract templates. Null → the PDF renders from
|
||||||
|
* the live template.
|
||||||
|
*/
|
||||||
|
export interface IContractDocumentSnapshot {
|
||||||
|
code?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
documentTitle?: string | null;
|
||||||
|
whereasClauses: string[];
|
||||||
|
articles: IContractDocumentArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable document draft returned for the accept/edit editor. */
|
||||||
|
export interface IContractDocumentDraft {
|
||||||
|
documentTitle: string | null;
|
||||||
|
whereasClauses: string[];
|
||||||
|
articles: IContractDocumentArticle[];
|
||||||
|
code: string | null;
|
||||||
|
name: string | null;
|
||||||
|
/** True once the document may no longer be edited/regenerated. */
|
||||||
|
locked: boolean;
|
||||||
|
generatedAt: string | null;
|
||||||
|
status: ContractStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export type ContractApprovalStepStatus =
|
export type ContractApprovalStepStatus =
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
| "APPROVED"
|
| "APPROVED"
|
||||||
@@ -584,6 +620,8 @@ export interface IContract extends BaseEntity {
|
|||||||
contractType?: string | null;
|
contractType?: string | null;
|
||||||
contractTemplateKey?: string | null;
|
contractTemplateKey?: string | null;
|
||||||
contractGeneratedAt?: string | null;
|
contractGeneratedAt?: string | null;
|
||||||
|
/** Per-contract frozen document (articles + WHEREAS) captured at staff accept. */
|
||||||
|
documentSnapshot?: IContractDocumentSnapshot | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
versionNumber: number;
|
versionNumber: number;
|
||||||
financialTerms?: string | null;
|
financialTerms?: string | null;
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ export enum WagonStatus {
|
|||||||
ImportReady = "IMPORT_READY",
|
ImportReady = "IMPORT_READY",
|
||||||
ExportReady = "EXPORT_READY",
|
ExportReady = "EXPORT_READY",
|
||||||
Maintenance = "MAINTENANCE",
|
Maintenance = "MAINTENANCE",
|
||||||
Retired = "RETIRED",
|
/** Formerly RETIRED — wagons pulled from circulation. */
|
||||||
|
Detained = "DETAINED",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WagonReadiness {
|
export enum WagonReadiness {
|
||||||
@@ -359,6 +360,8 @@ export interface IWagonTransferRequest extends BaseEntity {
|
|||||||
requestedByUserId?: string | null;
|
requestedByUserId?: string | null;
|
||||||
fulfilledByUserId?: string | null;
|
fulfilledByUserId?: string | null;
|
||||||
fulfilledAt?: string | null;
|
fulfilledAt?: string | null;
|
||||||
|
/** Why the wagons are needed — required for new requests, shown on the OCC queue. */
|
||||||
|
reason?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,10 +926,14 @@ export interface AvailableDaysForCargoQuery {
|
|||||||
freightType: "CONTAINER" | "BULK";
|
freightType: "CONTAINER" | "BULK";
|
||||||
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
|
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
|
||||||
cargoTypeCode?: string;
|
cargoTypeCode?: string;
|
||||||
|
/** Bulk cargo type id — preferred over code for the wagon-type gate. */
|
||||||
|
cargoTypeId?: string;
|
||||||
/** Total bulk weight in tons. */
|
/** Total bulk weight in tons. */
|
||||||
totalWeightTons?: number;
|
totalWeightTons?: number;
|
||||||
/** Container lines (size + quantity) for container freight. */
|
/** Container lines (size + quantity) for container freight. */
|
||||||
containers?: { containerSize: string; quantity: number }[];
|
containers?: { containerSize: string; quantity: number }[];
|
||||||
|
/** Container type ids — enables the exact wagon-type compatibility gate. */
|
||||||
|
containerTypeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user