mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin';
|
||||
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
type PositionTypeLike = { key?: string };
|
||||
type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| {
|
||||
position?: { permissions?: PermissionLike[] };
|
||||
position?: {
|
||||
permissions?: PermissionLike[];
|
||||
positionType?: PositionTypeLike | null;
|
||||
};
|
||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||
}
|
||||
| {
|
||||
positions?: { permissions?: PermissionLike[] }[];
|
||||
positions?: {
|
||||
permissions?: PermissionLike[];
|
||||
positionType?: PositionTypeLike | null;
|
||||
}[];
|
||||
}[]
|
||||
| null;
|
||||
};
|
||||
@@ -90,12 +97,110 @@ export function assertFreightPermission(
|
||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's IAM position-type keys (`iam.position_types.key`). A position
|
||||
* type is the platform's notion of a role — it is what carries permissions via
|
||||
* `iam.position_type_permissions` — and it is the vocabulary contract approval
|
||||
* chains are configured in.
|
||||
*
|
||||
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
|
||||
* an object on some tokens and an array on others.
|
||||
*
|
||||
* Note delegated positions carry no `positionType` in the token, so a delegate
|
||||
* is not reachable here — they authorize through the permission arm of
|
||||
* `assertCanApproveContractStep` instead.
|
||||
*/
|
||||
export function collectPositionTypeKeys(
|
||||
user: MeLikeUser | null | undefined,
|
||||
): string[] {
|
||||
const employee = user?.employee;
|
||||
if (!employee) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
if (pos.positionType?.key) keys.add(pos.positionType.key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
if (employee.position?.positionType?.key) {
|
||||
keys.add(employee.position.positionType.key);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy chain roles predate position types. Historical `approval_rules` and
|
||||
* in-flight `contract_approval_steps` rows still carry them, so map each to the
|
||||
* position types that stand in for it. Without this, an approver holding a
|
||||
* modern position type could not action an older step.
|
||||
*/
|
||||
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
|
||||
DIRECTOR: ['director', 'operation-director'],
|
||||
CEO: ['chief', 'deputy'],
|
||||
};
|
||||
|
||||
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||
};
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
const ANY_CONTRACT_APPROVE_PERMISSION = [
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
];
|
||||
|
||||
/**
|
||||
* May this caller action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
* `requiredRole` is an `iam.position_types.key` for chains configured by an
|
||||
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
|
||||
* A caller passes when any of these hold:
|
||||
*
|
||||
* - they are a super/organization admin (blanket bypass);
|
||||
* - their position type matches the step, directly or via a legacy alias;
|
||||
* - they hold the approve permission the legacy role maps to;
|
||||
* - they hold any contract approve permission — this covers delegates (whose
|
||||
* position type is absent from the token) and staff whose IAM position has
|
||||
* no position type assigned yet.
|
||||
*/
|
||||
export function assertCanApproveContractStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
): void {
|
||||
if (isFreightApprovalAdmin(user)) return;
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
|
||||
|
||||
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
`You are not the required approver (${requiredRole}) for this step.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCanApproveBookingStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bookings no longer run an approval chain — accepting an intake approves the
|
||||
* booking outright and generates its contract. The approval chain is now a
|
||||
* contract-only concern, so `freight.approval_rules` is read by contracts alone.
|
||||
*
|
||||
* Also widens the role columns: chain steps now reference IAM position-type
|
||||
* keys (`iam.position_types.key`), and real keys run past the old varchar(30)
|
||||
* (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail
|
||||
* on insert.
|
||||
*/
|
||||
export class DropBookingApprovalWidenRoles2410000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'DropBookingApprovalWidenRoles2410000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.booking_approval_step;`,
|
||||
);
|
||||
|
||||
for (const [table, column] of [
|
||||
['approval_rules', 'required_role'],
|
||||
['approval_rules', 'blocks_role'],
|
||||
['contract_approval_steps', 'required_role'],
|
||||
['contract_approval_steps', 'blocks_role'],
|
||||
] as const) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op: the booking approval chain is retired, so re-creating the table
|
||||
* would leave dead schema behind. Narrowing the role columns again would
|
||||
* truncate any position-type key already stored.
|
||||
*/
|
||||
public async down(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Audit trail for contract document edits. The document stays editable through
|
||||
* the whole approval chain (each approver may edit on their turn), so the
|
||||
* contract itself only ever holds the current snapshot — this table records who
|
||||
* changed which article, and when.
|
||||
*/
|
||||
export class CreateContractDocumentRevisions2420000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateContractDocumentRevisions2420000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_document_revisions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
actor_id uuid,
|
||||
actor_role varchar(64),
|
||||
step_id uuid,
|
||||
summary varchar(255),
|
||||
changes jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract
|
||||
ON freight.contract_document_revisions (contract_id, created_at DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Locomotive names must be unique so staff can identify a unit by name alone
|
||||
* (the card view leads with `name`, falling back to `code`). Uniqueness is:
|
||||
*
|
||||
* - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name;
|
||||
* - scoped to live rows — a decommissioned (soft-deleted) locomotive must not
|
||||
* hold its name hostage, matching how the fleet reuses yard codes;
|
||||
* - skipped for blank names — `name` stays optional, and NULL/'' rows are
|
||||
* excluded rather than colliding with each other.
|
||||
*
|
||||
* A partial expression index gives all three; a plain UNIQUE column cannot.
|
||||
*/
|
||||
export class UniqueLocomotiveName2430000000000 implements MigrationInterface {
|
||||
name = 'UniqueLocomotiveName2430000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every
|
||||
// copy after the oldest (…-2, …-3) so the index can build; the oldest row
|
||||
// keeps the original name. Deterministic on created_at, then id.
|
||||
await queryRunner.query(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
row_number() OVER (
|
||||
PARTITION BY lower(btrim(name))
|
||||
ORDER BY created_at, id
|
||||
) AS rn
|
||||
FROM "freight"."locomotives"
|
||||
WHERE deleted_at IS NULL
|
||||
AND name IS NOT NULL
|
||||
AND btrim(name) <> ''
|
||||
)
|
||||
UPDATE "freight"."locomotives" AS l
|
||||
SET name = btrim(ranked.name) || '-' || ranked.rn
|
||||
FROM ranked
|
||||
WHERE l.id = ranked.id
|
||||
AND ranked.rn > 1
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active"
|
||||
ON "freight"."locomotives" (lower(btrim("name")))
|
||||
WHERE "deleted_at" IS NULL
|
||||
AND "name" IS NOT NULL
|
||||
AND btrim("name") <> ''
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`,
|
||||
);
|
||||
// The de-duplicating renames are not reversed: the original names are no
|
||||
// longer recoverable, and restoring them would re-introduce the conflict.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export interface BookingNextStep {
|
||||
@@ -9,7 +8,11 @@ export interface BookingNextStep {
|
||||
|
||||
export function computeNextStep(
|
||||
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
|
||||
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
|
||||
/**
|
||||
* Retained for call-site compatibility — bookings no longer run an approval
|
||||
* chain, so this is always null. Approvals are a contract-only concern.
|
||||
*/
|
||||
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
|
||||
): BookingNextStep | null {
|
||||
const { status } = booking;
|
||||
|
||||
|
||||
@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const ruleEngineService = {
|
||||
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
|
||||
assertNoHardBlocks: jest.fn(),
|
||||
};
|
||||
const contractService = {
|
||||
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
ruleEngineService as never,
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
contractService as never,
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||
}
|
||||
|
||||
it('rejects accept when validity days is missing or non-positive', async () => {
|
||||
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
const [id, updates] = bookingsRepository.update.mock.calls[0];
|
||||
expect(id).toBe('b-1');
|
||||
expect(updates).toMatchObject({
|
||||
status: 'PENDING_APPROVAL',
|
||||
status: 'APPROVED',
|
||||
approvedByStaffId: 'staff-1',
|
||||
contractValidityDays: 10,
|
||||
});
|
||||
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
|
||||
});
|
||||
|
||||
it('instantiates the approval chain when accepting', async () => {
|
||||
const { service, ruleEngineService } = makeService();
|
||||
it('approves outright and generates the contract (no approval chain)', async () => {
|
||||
const { service, contractService } = makeService();
|
||||
await service.acceptIntake('b-1', 'staff-1', 30);
|
||||
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ freightType: 'CONTAINER' }),
|
||||
);
|
||||
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from './road.util';
|
||||
@@ -248,16 +246,6 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptIntake(
|
||||
bookingId: string,
|
||||
actorId: string,
|
||||
@@ -283,21 +271,33 @@ export class BookingTransitionService {
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "PENDING_APPROVAL",
|
||||
// Bookings no longer run a multi-step approval chain — accepting the intake
|
||||
// approves the booking outright and generates its contract. (The approval
|
||||
// chain is a contract-only concern now; see contract-transition.service.)
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "APPROVED",
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Generating the contract is best-effort: the acceptance is already
|
||||
// committed, so a failure here must not roll it back. The booking stays
|
||||
// APPROVED and staff can retry generation from the booking page.
|
||||
try {
|
||||
await this.contractService.generateContract(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
|
||||
`The booking is APPROVED — retry generation from the booking page.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.accepted(fresh);
|
||||
this.notifier.approved(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@@ -324,140 +324,6 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async approveStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Booking> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
let booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||
await this.ensureBookingApprovalSteps(booking);
|
||||
booking = await this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step || step.status !== "PENDING") {
|
||||
throw new BadRequestException(
|
||||
"Approval step not found or already actioned",
|
||||
);
|
||||
}
|
||||
|
||||
const next =
|
||||
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
"Approval steps must be completed in order",
|
||||
);
|
||||
}
|
||||
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Role ${requiredRole} is blocked for this step`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
"APPROVED",
|
||||
);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
|
||||
if (requiredRole === "LINE_STAFF") {
|
||||
updates.status = "APPROVED_PENDING_SIGNATURE";
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === "DIRECTOR") {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === "CEO") {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone =
|
||||
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
if (allDone) {
|
||||
updates.status = "APPROVED";
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.bookingsRepository.update(bookingId, updates as never);
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const generated = await this.contractService.generateContract(bookingId);
|
||||
const fresh = await this.bookingsService.findById(generated.id);
|
||||
this.notifier.approved(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async rejectStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step) throw new BadRequestException("Approval step not found");
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
"REJECTED",
|
||||
reason,
|
||||
);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(updated!.id);
|
||||
this.notifier.rejected(fresh, reason);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async customerSign(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||
@@ -1298,12 +1164,9 @@ export class BookingTransitionService {
|
||||
}
|
||||
let nextStep: BookingNextStep | null = null;
|
||||
try {
|
||||
const nextPending =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
nextStep = computeNextStep(booking, nextPending);
|
||||
// Bookings no longer carry an approval chain, so there is never a pending
|
||||
// approval step to hint at.
|
||||
nextStep = computeNextStep(booking, null);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
|
||||
@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectBookingDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
@@ -1023,47 +1021,6 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/approval-steps/:stepId/approve")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||
async approveStep(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/approval-steps/:stepId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: "Reject at approval step" })
|
||||
async rejectStep(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.rejectStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/contract/generate")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||
|
||||
@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
Booking,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingDocumentReview,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.originYard', 'oy')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'dy')
|
||||
.leftJoinAndSelect('booking.shippingLine', 'sl')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
@@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||
}
|
||||
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
async findNextPendingApprovalStep(
|
||||
bookingId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findApprovalStepById(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, id: stepId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get pending approval step for a role (must match next in sequence). */
|
||||
async findPendingApprovalStep(
|
||||
bookingId: string,
|
||||
requiredRole: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.requiredRole !== requiredRole) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
async completeApprovalStep(
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
status: 'APPROVED' | 'REJECTED',
|
||||
remarks?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
|
||||
status,
|
||||
actionedByStaffId: actorId,
|
||||
actionedAt: new Date(),
|
||||
remarks,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if all approval steps are approved. */
|
||||
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
|
||||
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
});
|
||||
return pending === 0;
|
||||
}
|
||||
|
||||
// ── Clearance document reviews ────────────────────────────────────────────
|
||||
|
||||
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
||||
@@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.where('booking.status IN (:...statuses)', { statuses });
|
||||
|
||||
if (options.excludeBulk) {
|
||||
@@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
// Contract reference for the list column + search (no entity relation on
|
||||
// Booking → contract, so join the entity by id and select just the
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
|
||||
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_approval_step' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
@Index(['bookingId', 'stepOrder'])
|
||||
export class BookingApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'approval_rule_id', type: 'uuid' })
|
||||
approvalRuleId!: string;
|
||||
|
||||
@ManyToOne(() => ApprovalRule)
|
||||
@JoinColumn({ name: 'approval_rule_id' })
|
||||
approvalRule?: ApprovalRule;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ApprovalStepStatus;
|
||||
|
||||
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
|
||||
actionedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
|
||||
actionedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||
@@ -557,8 +556,6 @@ export class Booking extends BaseEntity {
|
||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||
cargoModifiers?: BookingCargoModifier[];
|
||||
|
||||
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
|
||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||
rateSnapshots?: BookingRateSnapshot[];
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
ContractDocumentArticle,
|
||||
ContractDocumentSnapshot,
|
||||
} from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* One recorded change between two document snapshots. Granularity is per
|
||||
* article: a body edit is reported as "the body changed", not as a text diff.
|
||||
*/
|
||||
export type ContractDocumentChange =
|
||||
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_RENAMED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
fromTitle: string;
|
||||
}
|
||||
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_REORDERED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
fromOrder: number;
|
||||
toOrder: number;
|
||||
}
|
||||
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
||||
|
||||
type SnapshotLike = Pick<
|
||||
ContractDocumentSnapshot,
|
||||
'documentTitle' | 'whereasClauses' | 'articles'
|
||||
> | null;
|
||||
|
||||
/** Match on id when present, else on normalized title (editors may omit ids). */
|
||||
function articleKey(article: ContractDocumentArticle): string {
|
||||
return article.id || `title:${article.title.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function indexArticles(
|
||||
articles: ContractDocumentArticle[] | undefined,
|
||||
): Map<string, ContractDocumentArticle> {
|
||||
const map = new Map<string, ContractDocumentArticle>();
|
||||
for (const article of articles ?? []) {
|
||||
map.set(articleKey(article), article);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two document snapshots and describe what changed, article by article.
|
||||
* Returns an empty array when the snapshots are equivalent, so callers can skip
|
||||
* recording a no-op revision.
|
||||
*/
|
||||
export function diffSnapshots(
|
||||
before: SnapshotLike,
|
||||
after: SnapshotLike,
|
||||
): ContractDocumentChange[] {
|
||||
const changes: ContractDocumentChange[] = [];
|
||||
|
||||
const beforeTitle = before?.documentTitle ?? null;
|
||||
const afterTitle = after?.documentTitle ?? null;
|
||||
if (beforeTitle !== afterTitle && afterTitle !== null) {
|
||||
changes.push({
|
||||
kind: 'DOCUMENT_TITLE_CHANGED',
|
||||
title: afterTitle,
|
||||
fromTitle: beforeTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const beforeWhereas = before?.whereasClauses ?? [];
|
||||
const afterWhereas = after?.whereasClauses ?? [];
|
||||
const beforeWhereasSet = new Set(beforeWhereas);
|
||||
const afterWhereasSet = new Set(afterWhereas);
|
||||
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
|
||||
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
|
||||
if (whereasAdded > 0 || whereasRemoved > 0) {
|
||||
changes.push({
|
||||
kind: 'WHEREAS_CHANGED',
|
||||
added: whereasAdded,
|
||||
removed: whereasRemoved,
|
||||
});
|
||||
}
|
||||
|
||||
const beforeArticles = indexArticles(before?.articles);
|
||||
const afterArticles = indexArticles(after?.articles);
|
||||
|
||||
for (const [key, article] of afterArticles) {
|
||||
const previous = beforeArticles.get(key);
|
||||
if (!previous) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_ADDED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous.title !== article.title) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_RENAMED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromTitle: previous.title,
|
||||
});
|
||||
}
|
||||
if (previous.body !== article.body) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_BODY_CHANGED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
}
|
||||
if (previous.order !== article.order) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_REORDERED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromOrder: previous.order,
|
||||
toOrder: article.order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, article] of beforeArticles) {
|
||||
if (afterArticles.has(key)) continue;
|
||||
changes.push({
|
||||
kind: 'ARTICLE_REMOVED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
if (changes.length === 0) return 'No changes';
|
||||
|
||||
const articleVerbs: Record<string, string> = {
|
||||
ARTICLE_ADDED: 'added',
|
||||
ARTICLE_REMOVED: 'removed',
|
||||
ARTICLE_RENAMED: 'renamed',
|
||||
ARTICLE_BODY_CHANGED: 'edited',
|
||||
ARTICLE_REORDERED: 'reordered',
|
||||
};
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const verb = articleVerbs[change.kind];
|
||||
if (verb) {
|
||||
counts.set(verb, (counts.get(verb) ?? 0) + 1);
|
||||
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
|
||||
parts.push('document title changed');
|
||||
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||
parts.push('recitals changed');
|
||||
}
|
||||
}
|
||||
|
||||
const articleParts = [...counts.entries()].map(
|
||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||
);
|
||||
|
||||
return [...articleParts, ...parts].join(', ');
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||
|
||||
export interface RecordRevisionInput {
|
||||
contractId: string;
|
||||
before: ContractDocumentSnapshot | null;
|
||||
after: ContractDocumentSnapshot | null;
|
||||
actorId?: string | null;
|
||||
actorRole?: string | null;
|
||||
stepId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractDocumentHistoryService {
|
||||
private readonly logger = new Logger(ContractDocumentHistoryService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ContractDocumentRevision)
|
||||
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Append a revision describing what an edit changed. Best-effort: recording
|
||||
* history must never break the edit that triggered it, so failures are logged
|
||||
* and swallowed. A no-op edit records nothing.
|
||||
*/
|
||||
async record(input: RecordRevisionInput): Promise<void> {
|
||||
try {
|
||||
const changes = diffSnapshots(input.before, input.after);
|
||||
if (changes.length === 0) return;
|
||||
|
||||
await this.revisionRepo.save(
|
||||
this.revisionRepo.create({
|
||||
contractId: input.contractId,
|
||||
actorId: input.actorId ?? null,
|
||||
actorRole: input.actorRole ?? null,
|
||||
stepId: input.stepId ?? null,
|
||||
summary: summarizeChanges(changes),
|
||||
changes,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revision history for a contract, newest first. */
|
||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
return this.revisionRepo.find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
|
||||
locked: boolean;
|
||||
/** Whether the requesting user is the approver whose turn it is to edit. */
|
||||
editableByMe: boolean;
|
||||
/** Role holding editing rights right now, for "locked because…" messaging. */
|
||||
nextApproverRole: string | null;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Approval chains are configured in IAM position types, so a step's role no
|
||||
* longer maps onto the contract's fixed approver columns. These sets keep those
|
||||
* legacy columns populated for the roles that still correspond to one — both the
|
||||
* original role strings on historical rows and the position types that replaced
|
||||
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
|
||||
* which is the source of truth.
|
||||
*/
|
||||
const LEGACY_STAFF_ROLES = new Set([
|
||||
'LINE_STAFF',
|
||||
'employee',
|
||||
'teamLeader',
|
||||
'officeHead',
|
||||
'recordOfficer',
|
||||
]);
|
||||
const LEGACY_DIRECTOR_ROLES = new Set([
|
||||
'DIRECTOR',
|
||||
'director',
|
||||
'operation-director',
|
||||
]);
|
||||
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
@@ -108,6 +136,7 @@ export class ContractTransitionService {
|
||||
private readonly logger = new Logger(ContractTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
@@ -255,18 +284,22 @@ export class ContractTransitionService {
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const editableByMe = await this.documentIsEditableBy(contract, user);
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
locked: !editableByMe,
|
||||
editableByMe,
|
||||
nextApproverRole: await this.nextApproverRole(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
@@ -281,10 +314,12 @@ export class ContractTransitionService {
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
user?: TCurrentUser | null,
|
||||
actorId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.assertDocumentEditable(contract, user);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
@@ -296,9 +331,25 @@ export class ContractTransitionService {
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
const next = this.normalizeSnapshot(merged);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
documentSnapshot: next,
|
||||
} as never);
|
||||
|
||||
// Audit the edit after it lands. Recording history must never break the
|
||||
// edit itself, so the history service swallows its own failures.
|
||||
const step = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contractId,
|
||||
);
|
||||
await this.documentHistory.record({
|
||||
contractId,
|
||||
before: current,
|
||||
after: next,
|
||||
actorId: actorId ?? null,
|
||||
actorRole: step?.requiredRole ?? null,
|
||||
stepId: step?.id ?? null,
|
||||
});
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -360,23 +411,54 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The contract document stays editable for the whole approval chain, but only
|
||||
* by the approver whose turn it is: whoever can action the next pending step.
|
||||
* Approving therefore hands editing rights to the next approver in the chain.
|
||||
*
|
||||
* Edits never reset approvals already given — earlier approvers stay approved.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
private async documentIsEditableBy(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<boolean> {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
if (!next) return false;
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
assertCanApproveContractStep(user, next.requiredRole);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
/** The role that currently holds editing rights, for UI messaging. */
|
||||
private async nextApproverRole(contract: Contract): Promise<string | null> {
|
||||
if (contract.status !== 'PENDING_APPROVAL') return null;
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
return next?.requiredRole ?? null;
|
||||
}
|
||||
|
||||
private async assertDocumentEditable(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<void> {
|
||||
if (await this.documentIsEditableBy(contract, user)) return;
|
||||
|
||||
const role = await this.nextApproverRole(contract);
|
||||
throw new ConflictException(
|
||||
role
|
||||
? `The contract document can only be edited by the current approver (${role}).`
|
||||
: 'The contract document is locked — the contract has advanced beyond approval.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,25 +621,11 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Contract> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
// 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);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
@@ -567,31 +635,34 @@ export class ContractTransitionService {
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException('Approval steps must be completed in order');
|
||||
}
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
if (step.blocksRole && step.blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
|
||||
// The role is the step's own — never the caller's claim about themselves.
|
||||
const requiredRole = step.requiredRole;
|
||||
if (authUser) {
|
||||
assertCanApproveContractStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
// Record who acted on this step, but DO NOT advance the contract status here —
|
||||
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
|
||||
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
|
||||
// every step in the chain is complete; until then the contract stays in
|
||||
// PENDING_APPROVAL so the next required role can act.
|
||||
// approving one step must not finalize the chain while later steps are still
|
||||
// pending. Status only moves to APPROVED once every step in the chain is
|
||||
// complete; until then the contract stays in PENDING_APPROVAL so the next
|
||||
// required approver can act.
|
||||
//
|
||||
// `contract_approval_steps` is the source of truth for who approved what — a
|
||||
// chain is an arbitrary sequence of position types and cannot be represented
|
||||
// by fixed columns. The legacy columns below are still stamped, best-effort,
|
||||
// for the three roles that map onto them so older readers keep working.
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
@@ -605,14 +676,19 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// Every step approved → CONTRACT_READY. The document was already generated
|
||||
// (and reviewed) at the accept stage, so we reuse it rather than
|
||||
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
||||
// Final approval is what produces the contract PDF — until now there was
|
||||
// only a live preview. The approval steps are already committed, so a
|
||||
// render failure must not roll them back; surface it instead of swallowing
|
||||
// it, since an APPROVED contract with no document needs operator action.
|
||||
try {
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
this.logger.error(
|
||||
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'All approvals were recorded, but generating the contract PDF failed. ' +
|
||||
'Retry generation from the contract page.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -620,24 +696,13 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff (re)generate the contract PDF. Two stages:
|
||||
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
||||
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
||||
* 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.
|
||||
* Retry path for a contract that finished approval but whose PDF failed to
|
||||
* render (Chromium unavailable, etc.). The normal flow generates the document
|
||||
* automatically on the final approval — there is no manual generate step
|
||||
* before that, only the live preview.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
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']);
|
||||
await this.renderContractDocument(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
@@ -652,11 +717,17 @@ export class ContractTransitionService {
|
||||
* 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> {
|
||||
private async renderContractDocument(
|
||||
contract: Contract,
|
||||
options: { strict?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
// Strict callers (final approval) need to know the PDF is missing — it is
|
||||
// the artifact of the completed chain, not a cache that can refill later.
|
||||
if (options.strict) throw err;
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
@@ -668,15 +739,14 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
|
||||
* This is the only point at which the document is produced: approvers review a
|
||||
* live preview, and the final approval is what turns it into a PDF. Renders
|
||||
* unconditionally so the file reflects every edit made during the chain.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.renderContractDocument(contract, { strict: true });
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
@@ -61,7 +62,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
@ApiBearerAuth()
|
||||
export class ContractsController {
|
||||
constructor(
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly transitionService: ContractTransitionService,
|
||||
@@ -353,8 +354,22 @@ export class ContractsController {
|
||||
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);
|
||||
getContractDocumentDraft(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Editability depends on WHO is asking — only the approver whose turn it is
|
||||
// may edit — so the caller is part of the draft lookup.
|
||||
return this.transitionService.getContractDocumentDraft(id, user);
|
||||
}
|
||||
|
||||
@Get(':id/document/revisions')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({
|
||||
summary: 'Audit trail of edits to this contract\'s document (newest first)',
|
||||
})
|
||||
getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.documentHistory.list(id);
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@@ -366,8 +381,14 @@ export class ContractsController {
|
||||
updateContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDocumentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.updateContractDocument(id, dto);
|
||||
return this.transitionService.updateContractDocument(
|
||||
id,
|
||||
dto,
|
||||
user,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@@ -397,23 +418,20 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Whether this caller may approve depends on the step's own required role
|
||||
// (an IAM position type), so the service resolves the step and authorizes
|
||||
// against it — the client never declares its own role.
|
||||
return this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './entities/contract-signature.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
@@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractSignature,
|
||||
ContractApprovalStep,
|
||||
ContractReviewNote,
|
||||
ContractDocumentRevision,
|
||||
ContractClearanceCycle,
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
@@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractDocumentHistoryService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
|
||||
@@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 40 })
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import type { ContractDocumentChange } from '../contract-document-diff.util';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* Append-only audit of contract document edits. The document stays editable
|
||||
* through the whole approval chain, so this records who changed which article
|
||||
* and when — the contract itself only ever holds the current snapshot.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_document_revisions' })
|
||||
@Index(['contractId'])
|
||||
export class ContractDocumentRevision extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/** The approval step's required role at the time of the edit. */
|
||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||
actorRole?: string | null;
|
||||
|
||||
@Column({ name: 'step_id', type: 'uuid', nullable: true })
|
||||
stepId?: string | null;
|
||||
|
||||
@Column({ name: 'summary', type: 'varchar', length: 255, nullable: true })
|
||||
summary?: string | null;
|
||||
|
||||
@Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` })
|
||||
changes!: ContractDocumentChange[];
|
||||
}
|
||||
@@ -27,6 +27,13 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
|
||||
/**
|
||||
* Optional, but unique when set. Enforced in the DB by the partial expression
|
||||
* index `UQ_locomotives_name_active` (see UniqueLocomotiveName2430000000000):
|
||||
* case- and whitespace-insensitive, live rows only, blanks exempt. Not a
|
||||
* `unique: true` column — that would be case-sensitive and would let a
|
||||
* soft-deleted locomotive keep holding its name.
|
||||
*/
|
||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
|
||||
@@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live locomotive already holding this name, compared the same way the
|
||||
* `UQ_locomotives_name_active` index compares: case- and whitespace-
|
||||
* insensitive, soft-deleted rows excluded. `excludeId` skips the row being
|
||||
* updated so it can keep its own name.
|
||||
*/
|
||||
findByName(name: string, excludeId?: string): Promise<Locomotive | null> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('locomotive')
|
||||
.where('lower(btrim(locomotive.name)) = lower(btrim(:name))', { name });
|
||||
|
||||
if (excludeId) {
|
||||
qb.andWhere('locomotive.id != :excludeId', { excludeId });
|
||||
}
|
||||
|
||||
// createQueryBuilder already filters soft-deleted rows (no withDeleted()).
|
||||
return qb.getOne();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,23 @@ export class LocomotivesService {
|
||||
return `LOCO-${String(max + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a name already worn by another live locomotive. Compared
|
||||
* case-insensitively on the trimmed value so this matches the DB index
|
||||
* `UQ_locomotives_name_active` — otherwise a clash the guard waved through
|
||||
* would surface as a raw 500 from the index instead of a 409. `excludeId`
|
||||
* lets an update keep its own name.
|
||||
*/
|
||||
private async assertNameAvailable(name: string, excludeId?: string): Promise<void> {
|
||||
const clash = await this.locomotivesRepository.findByName(name, excludeId);
|
||||
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
`Locomotive name "${name.trim()}" is already used by ${clash.code}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||
const code = dto.code?.trim() || (await this.generateCode());
|
||||
|
||||
@@ -68,9 +85,15 @@ export class LocomotivesService {
|
||||
throw new ConflictException(`Locomotive code ${code} already exists`);
|
||||
}
|
||||
|
||||
// Name stays optional; only a non-blank one has to be unique.
|
||||
const name = dto.name?.trim() || null;
|
||||
if (name) {
|
||||
await this.assertNameAvailable(name);
|
||||
}
|
||||
|
||||
return this.locomotivesRepository.create({
|
||||
code,
|
||||
name: dto.name?.trim() || null,
|
||||
name,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
@@ -107,6 +130,15 @@ export class LocomotivesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Only when the caller actually sends a name — an omitted field keeps the
|
||||
// current one, and clearing it to blank is allowed.
|
||||
if (dto.name !== undefined) {
|
||||
const nextName = dto.name?.trim() || null;
|
||||
if (nextName) {
|
||||
await this.assertNameAvailable(nextName, id);
|
||||
}
|
||||
}
|
||||
|
||||
// A locomotive coupled to a built train follows the train: its yard and
|
||||
// status are owned by the train-builder flow, not this generic PATCH.
|
||||
const link = await this.findTrainLink(id);
|
||||
|
||||
@@ -31,6 +31,15 @@ export class ApprovalRulesController {
|
||||
return this.service.findChain(flag === 'true');
|
||||
}
|
||||
|
||||
@Get('position-types')
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({
|
||||
summary: 'IAM position types to choose from when building an approval chain',
|
||||
})
|
||||
listPositionTypes() {
|
||||
return this.service.listPositionTypes();
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||
|
||||
export class CreateApprovalRuleDto {
|
||||
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
|
||||
@IsBoolean()
|
||||
@@ -19,9 +17,12 @@ export class CreateApprovalRuleDto {
|
||||
@IsUUID('4')
|
||||
insertAfterId?: string;
|
||||
|
||||
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'IAM position-type key required to action this step (see GET /approval-rules/position-types)',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@MaxLength(64)
|
||||
requiredRole!: string;
|
||||
|
||||
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
|
||||
@@ -29,9 +30,11 @@ export class CreateApprovalRuleDto {
|
||||
@MaxLength(50)
|
||||
actionLabel!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'IAM position-type key explicitly blocked from actioning this step',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@MaxLength(64)
|
||||
blocksRole?: string;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity {
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'action_label', type: 'varchar', length: 50 })
|
||||
actionLabel!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
@@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ApprovalRule,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
]),
|
||||
// Team notifications for the priority-rule approval workflow.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import {
|
||||
@@ -23,15 +22,10 @@ import {
|
||||
IRatesRepository,
|
||||
RATES_REPOSITORY,
|
||||
} from './interfaces/rates.repository.interface';
|
||||
import {
|
||||
IApprovalRulesRepository,
|
||||
APPROVAL_RULES_REPOSITORY,
|
||||
} from './interfaces/approval-rules.repository.interface';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
@@ -124,8 +118,6 @@ export class RuleEngineService {
|
||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepo: IRatesRepository,
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly shippingLinesRepo: IShippingLinesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -390,79 +382,6 @@ export class RuleEngineService {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
||||
*/
|
||||
async ensureDefaultApprovalRules(): Promise<void> {
|
||||
for (const flag of [false, true] as const) {
|
||||
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
||||
if (existing.length > 0) continue;
|
||||
|
||||
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
||||
(r) => r.requiresDirectorApproval === flag,
|
||||
);
|
||||
for (const row of rows) {
|
||||
await this.approvalRulesRepo.create({
|
||||
requiresDirectorApproval: row.requiresDirectorApproval,
|
||||
stepOrder: row.stepOrder,
|
||||
requiredRole: row.requiredRole,
|
||||
actionLabel: row.actionLabel,
|
||||
blocksRole: row.blocksRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||
*/
|
||||
async instantiateApprovalSteps(
|
||||
bookingId: string,
|
||||
options: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
},
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||
}
|
||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
requiresDirectorApproval,
|
||||
);
|
||||
|
||||
if (chain.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
const steps: BookingApprovalStep[] = [];
|
||||
|
||||
for (const rule of chain) {
|
||||
const step = stepRepo.create({
|
||||
bookingId,
|
||||
approvalRuleId: rule.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
status: 'PENDING',
|
||||
});
|
||||
steps.push(await stepRepo.save(step));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot only the rates used in a booking's final price.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
@@ -17,6 +18,7 @@ export class ApprovalRulesService {
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly repository: IApprovalRulesRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** List approval rules — standard paginated envelope with server-side search. */
|
||||
@@ -29,6 +31,21 @@ export class ApprovalRulesService {
|
||||
return this.repository.findChainForCargo(requiresDirectorApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM position types, for the approval-step role picker. A chain step names
|
||||
* the position type that must approve it, so this is the vocabulary an admin
|
||||
* builds chains from. Read straight from the shared `iam` schema — the same
|
||||
* pattern the freight API already uses for `iam.users`.
|
||||
*/
|
||||
async listPositionTypes(): Promise<Array<{ label: string; value: string }>> {
|
||||
const rows = await this.dataSource.query<
|
||||
Array<{ key: string; label: string }>
|
||||
>(`SELECT key, COALESCE(name->>'en', key) AS label
|
||||
FROM iam.position_types
|
||||
ORDER BY 2`);
|
||||
return rows.map((row) => ({ label: row.label, value: row.key }));
|
||||
}
|
||||
|
||||
/** Get an approval rule by ID. */
|
||||
async findById(id: string): Promise<ApprovalRule> {
|
||||
const entity = await this.repository.findById(id);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import type { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
|
||||
/**
|
||||
* A GRN is the receipt for cargo entering the warehouse, so unloadBooking must
|
||||
* issue one for every direction — import as well as export. It used to mint only
|
||||
* for export, leaving import cargo received with no GRN.
|
||||
*/
|
||||
function makeService(opts: {
|
||||
tradeDirection: string | null;
|
||||
existing?: { id: string; grnNumber: string | null };
|
||||
}) {
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
|
||||
const inventoryRepository = {
|
||||
findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []),
|
||||
update: jest.fn((id: string, patch: Record<string, unknown>) => {
|
||||
updated.push({ id, patch });
|
||||
return Promise.resolve();
|
||||
}),
|
||||
create: jest.fn((row: Record<string, unknown>) => {
|
||||
created.push(row);
|
||||
return Promise.resolve({ id: 'new-inv', ...row });
|
||||
}),
|
||||
};
|
||||
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
service.inventoryRepository = inventoryRepository;
|
||||
service.dataSource = {
|
||||
query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]),
|
||||
};
|
||||
// Location comes straight from the dto in these cases, so pickDefaultLocation
|
||||
// is never reached; findById just echoes what was written.
|
||||
service.findById = jest.fn((id: string) =>
|
||||
Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }),
|
||||
);
|
||||
|
||||
const dto: UnloadBookingDto = {
|
||||
warehouseId: 'w1',
|
||||
yardId: 'y1',
|
||||
zoneId: 'z1',
|
||||
} as UnloadBookingDto;
|
||||
|
||||
return { service: service as unknown as WarehouseInventoryService, dto, created, updated };
|
||||
}
|
||||
|
||||
describe('unloadBooking — GRN issuance', () => {
|
||||
it('issues an IMPORT GRN when unloading a fresh import booking', async () => {
|
||||
const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' });
|
||||
|
||||
await service.unloadBooking('b-import', dto);
|
||||
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||
});
|
||||
|
||||
it('still issues an EXPORT GRN', async () => {
|
||||
const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' });
|
||||
|
||||
await service.unloadBooking('b-export', dto);
|
||||
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/);
|
||||
});
|
||||
|
||||
it('mints a GRN for an existing import row that has none', async () => {
|
||||
const { service, dto, updated } = makeService({
|
||||
tradeDirection: 'IMPORT',
|
||||
existing: { id: 'inv-1', grnNumber: null },
|
||||
});
|
||||
|
||||
await service.unloadBooking('b-import', dto);
|
||||
|
||||
expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||
});
|
||||
|
||||
it('does not reissue when the row already has a GRN', async () => {
|
||||
const { service, dto, updated } = makeService({
|
||||
tradeDirection: 'IMPORT',
|
||||
existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' },
|
||||
});
|
||||
|
||||
await service.unloadBooking('b-import', dto);
|
||||
|
||||
expect(updated[0].patch).not.toHaveProperty('grnNumber');
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,15 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.opsStats();
|
||||
}
|
||||
|
||||
@Get('trucks-on-site')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({
|
||||
summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
|
||||
})
|
||||
trucksOnSite() {
|
||||
return this.inventoryService.trucksOnSite();
|
||||
}
|
||||
|
||||
@Get('zone-occupancy')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||
|
||||
@@ -410,6 +410,77 @@ export class WarehouseInventoryService {
|
||||
* - trucksOnSite: customer trucks arrived but not departed
|
||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||
*/
|
||||
/**
|
||||
* Every truck currently inside the yard, across all bookings — the list behind
|
||||
* the `trucksOnSite` figure on the ops dashboard, which until now could only
|
||||
* be counted and never opened.
|
||||
*
|
||||
* Covers both haulage paths because the gate does: a customer's own truck and
|
||||
* an EDR last-mile truck arrive at the same barrier and need the same paper.
|
||||
* "On site" means arrived and not yet departed.
|
||||
*/
|
||||
async trucksOnSite(): Promise<
|
||||
Array<{
|
||||
source: 'CUSTOMER' | 'EDR';
|
||||
assignmentId: string;
|
||||
plateNumber: string | null;
|
||||
driverName: string | null;
|
||||
truckType: string | null;
|
||||
arrivedAt: string | null;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
containers: string | null;
|
||||
}>
|
||||
> {
|
||||
return this.dataSource.query(
|
||||
`SELECT 'CUSTOMER' AS "source",
|
||||
a.id AS "assignmentId",
|
||||
a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.arrived_at AS "arrivedAt",
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
|
||||
FROM freight.customer_truck_containers c
|
||||
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
|
||||
FROM freight.customer_truck_assignments a
|
||||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.arrived_at IS NOT NULL
|
||||
AND a.departed_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'EDR' AS "source",
|
||||
va.id AS "assignmentId",
|
||||
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
|
||||
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
|
||||
v.vehicle_type AS "truckType",
|
||||
va.arrived_at AS "arrivedAt",
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
|
||||
FROM freight.last_mile_vehicle_containers lvc
|
||||
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE va.deleted_at IS NULL
|
||||
AND va.arrived_at IS NOT NULL
|
||||
AND va.departed_at IS NULL
|
||||
|
||||
ORDER BY "arrivedAt" ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
receivedYesterday: number;
|
||||
@@ -1067,14 +1138,15 @@ export class WarehouseInventoryService {
|
||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||||
// a train without one. Import GRN handling is left untouched.
|
||||
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||
// gets one on unload — import as well as export. The direction only decides
|
||||
// the GRN prefix, not whether one is issued.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||||
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1095,10 +1167,10 @@ export class WarehouseInventoryService {
|
||||
zoneId: location.zoneId,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
||||
...(isExport && !existing[0].grnNumber
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||
...(existing[0].grnNumber
|
||||
? {}
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1113,9 +1185,7 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
...(isExport
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1202,8 +1272,18 @@ export class WarehouseInventoryService {
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
-- fallback for single-truck bookings written before that table.
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
@@ -1334,8 +1414,14 @@ export class WarehouseInventoryService {
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
@@ -1794,8 +1880,18 @@ export class WarehouseInventoryService {
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
-- fallback for single-truck bookings written before that table.
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||
FROM freight.customer_truck_assignments cta
|
||||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
|
||||
@@ -125,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import IntercityPage from "./pages/warehouses/IntercityPage";
|
||||
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
@@ -465,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
// Yard-wide, not per-direction: the gate sees import and export
|
||||
// trucks at the same barrier.
|
||||
label: "Trucks on Site",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
@@ -960,6 +968,7 @@ const App = () => {
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
||||
import {
|
||||
buildApproveActionForStep,
|
||||
canActOnApprovalStep,
|
||||
getNextPendingApprovalStep,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
interface ApprovalStepsCardProps {
|
||||
booking: BookingDetail;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Approval chain with inline approve on the current pending step. */
|
||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||
[booking.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = getNextPendingApprovalStep(steps);
|
||||
const summary = formatApprovalProgress(booking.status, steps);
|
||||
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||
|
||||
const openApprove = (step: BookingApprovalStep) => {
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeApprove = () => {
|
||||
setConfirmOpen(false);
|
||||
setPendingStep(null);
|
||||
};
|
||||
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeApprove();
|
||||
else setConfirmOpen(true);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={booking.reference}
|
||||
inputValue=""
|
||||
onInputChange={() => {}}
|
||||
onConfirm={runApprove}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
steps,
|
||||
user,
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
}: {
|
||||
step: BookingApprovalStep;
|
||||
steps: BookingApprovalStep[];
|
||||
user: ReturnType<typeof useAuth>["user"];
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: (step: BookingApprovalStep) => void;
|
||||
}) {
|
||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "edr-green"
|
||||
: step.status === "REJECTED"
|
||||
? "red"
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
{step.remarks && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.remarks}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{canApprove && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={() => onApprove(step)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isClearanceNavAction,
|
||||
isContractNavAction,
|
||||
@@ -37,7 +36,6 @@ export function BookingActionsMenu({
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
};
|
||||
@@ -192,28 +190,6 @@ function ActionDialog({
|
||||
}}
|
||||
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||
confirmDisabled={flow.confirmDisabled}
|
||||
extra={
|
||||
flow.detailLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading approval steps…
|
||||
</Text>
|
||||
) : pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="orange.9"
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
}}
|
||||
>
|
||||
No pending approval step. Refresh the page after staff accept, or reject the
|
||||
booking.
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BookingApprovalProgressCellProps {
|
||||
row: BookingListRow;
|
||||
}
|
||||
|
||||
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
|
||||
const summary = formatApprovalProgress(row.status, row.approvalSteps);
|
||||
|
||||
return (
|
||||
<div className="min-w-[8.5rem] py-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{summary.label}
|
||||
</p>
|
||||
{summary.detail ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{summary.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { Group, Text, Badge, Timeline } from "@mantine/core";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import {
|
||||
approvalStatusColor,
|
||||
formatDateTime,
|
||||
type BookingApprovalStepView,
|
||||
} from "./booking-detail.styles";
|
||||
|
||||
export interface BookingApprovalCardProps {
|
||||
steps: BookingApprovalStepView[];
|
||||
approvedCount: number;
|
||||
}
|
||||
|
||||
/** Vertical timeline of the booking's approval chain. */
|
||||
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
|
||||
return (
|
||||
<SectionCard
|
||||
icon={CheckCircle}
|
||||
title="Approval Workflow"
|
||||
accent="edr-green"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{approvedCount} / {steps.length} approved
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{steps.map((step) => (
|
||||
<Timeline.Item
|
||||
key={step.id}
|
||||
color={approvalStatusColor(step.status)}
|
||||
bullet={
|
||||
step.status === "APPROVED" ? (
|
||||
<CheckCircle size={14} />
|
||||
) : step.status === "REJECTED" ? (
|
||||
<XCircle size={14} />
|
||||
) : (
|
||||
<Clock size={14} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{step.requiredRole.replace(/_/g, " ")}
|
||||
</Text>
|
||||
<Badge
|
||||
color={approvalStatusColor(step.status)}
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{step.actionedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(step.actionedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -102,14 +102,6 @@ export interface BookingContainerView {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BookingApprovalStepView {
|
||||
id: string;
|
||||
stepOrder: number;
|
||||
requiredRole: string;
|
||||
status: string;
|
||||
actionedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface BookingReviewNoteView {
|
||||
id: string;
|
||||
note: string;
|
||||
@@ -150,7 +142,6 @@ export interface BookingDetailView {
|
||||
cargoType?: BookingNamedRefView;
|
||||
shippingLine?: BookingNamedRefView;
|
||||
bookingContainers?: BookingContainerView[];
|
||||
approvalSteps?: BookingApprovalStepView[];
|
||||
reviewNotes?: BookingReviewNoteView[];
|
||||
files?: BookingFileView[];
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export * from "./BookingLifecycleStepper";
|
||||
export * from "./BookingRouteCard";
|
||||
export * from "./BookingContainersCard";
|
||||
export * from "./BookingContainerUnitsCard";
|
||||
export * from "./BookingApprovalCard";
|
||||
export * from "./BookingReviewNotesCard";
|
||||
export * from "./BookingPaymentCard";
|
||||
export * from "./BookingPaymentCountdownCard";
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
getBookingActions,
|
||||
getNextPendingApprovalStep,
|
||||
type BookingActionContext,
|
||||
type BookingActionDef,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
/** A contract validity window must be a whole number of days, 1–365. */
|
||||
function isValidValidityDays(value: string): boolean {
|
||||
@@ -24,22 +23,11 @@ export function useBookingActionDialog(
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const needsApprovalSteps =
|
||||
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
|
||||
// Bookings no longer have an approval chain, so the dialog needs nothing
|
||||
// beyond the list-row context it was handed.
|
||||
const detailLoading = false;
|
||||
|
||||
const needsApprovalContext =
|
||||
context.status === "PENDING_APPROVAL" ||
|
||||
context.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useBookingDetail(
|
||||
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
|
||||
);
|
||||
|
||||
const mergedContext: BookingActionContext = {
|
||||
...context,
|
||||
approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
|
||||
reference: detail?.reference ?? context.reference,
|
||||
};
|
||||
const mergedContext: BookingActionContext = { ...context };
|
||||
|
||||
const { user } = useAuth();
|
||||
const mutations = useBookingMutations(bookingId);
|
||||
@@ -86,24 +74,6 @@ export function useBookingActionDialog(
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
case "approve": {
|
||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||
if (!step) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: step.id, requiredRole: step.requiredRole },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "rejectApproval": {
|
||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||
if (!step) return;
|
||||
mutations.rejectStep.mutate(
|
||||
{ stepId: step.id, reason: inputValue.trim() },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "viewContract":
|
||||
break;
|
||||
case "startTransit":
|
||||
@@ -122,16 +92,12 @@ export function useBookingActionDialog(
|
||||
pendingAction,
|
||||
inputValue,
|
||||
selectedFile,
|
||||
mergedContext.approvalSteps,
|
||||
mutations,
|
||||
closeDialog,
|
||||
]);
|
||||
|
||||
const confirmDisabled =
|
||||
mutations.isPending ||
|
||||
(needsApprovalSteps && detailLoading) ||
|
||||
(pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
|
||||
(pendingAction?.input === "file" && !selectedFile) ||
|
||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileCheck,
|
||||
Eye,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
Zap,
|
||||
@@ -16,8 +15,10 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
|
||||
import { ContractPreviewModal } from "@/components/contracts/ContractPreviewModal";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||
@@ -51,11 +52,21 @@ export function ContractActionsToolbar({
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// Whether the document is editable depends on WHO is viewing — only the
|
||||
// approver whose turn it is may edit — so the server decides, not the client.
|
||||
const { data: draft } = useQuery({
|
||||
queryKey: ["contracts", contract.id, "document-draft"],
|
||||
queryFn: () => contractsService.getContractDocumentDraft(contract.id),
|
||||
enabled: contract.status === "PENDING_APPROVAL",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Admin-configured validity durations (days) for the accept dialog. Staff can
|
||||
// only pick one of these — no free-typing. Read-only setting, fetched once.
|
||||
const { data: validitySetting, isLoading: validityLoading } = useQuery({
|
||||
@@ -87,18 +98,11 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
|
||||
// can edit this contract's articles and (re)generate its PDF. The first
|
||||
// approval action locks the document.
|
||||
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 =
|
||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||
// The document stays editable for the whole approval chain, but only by the
|
||||
// approver whose turn it is. The server resolves that against the caller's
|
||||
// position type; the client cannot derive it.
|
||||
const canEditDocument = Boolean(draft?.editableByMe);
|
||||
const inApproval = status === "PENDING_APPROVAL";
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
// generated contract before signing) — no sign button in this toolbar.
|
||||
const canViewContract =
|
||||
@@ -154,56 +158,41 @@ export function ContractActionsToolbar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEditGenerate && (
|
||||
{inApproval && (
|
||||
<>
|
||||
<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."}
|
||||
{canEditDocument
|
||||
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
|
||||
: draft?.nextApproverRole
|
||||
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
|
||||
: "Awaiting approval."}
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
documentGenerated ? (
|
||||
<RefreshCw size={16} />
|
||||
) : (
|
||||
<FileCheck size={16} />
|
||||
)
|
||||
}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
{documentGenerated ? "Regenerate contract" : "Generate contract"}
|
||||
Preview document
|
||||
</Button>
|
||||
{canEditDocument && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsManualGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileCheck size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canViewContract && (
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -233,8 +222,7 @@ export function ContractActionsToolbar({
|
||||
the customer creates the booking in the portal. */}
|
||||
|
||||
{!canAccept &&
|
||||
!canEditGenerate &&
|
||||
!needsManualGenerate &&
|
||||
!inApproval &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance && (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -267,6 +255,12 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
/>
|
||||
|
||||
<ContractPreviewModal
|
||||
opened={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
contractId={contract.id}
|
||||
/>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
opened={changesOpen}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -31,7 +31,6 @@ export function ContractApprovalStepsCard({
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectStepRow, setRejectStepRow] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -48,17 +47,9 @@ export function ContractApprovalStepsCard({
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
// Approvers must review the GENERATED contract document before approving. If
|
||||
// it has not been generated yet, block the approval and tell staff to generate
|
||||
// it first (via "Generate contract" in Staff actions) — mirrors the server
|
||||
// guard so the user sees a clear reason, not a generic failure toast.
|
||||
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||
|
||||
// Approvers review a live preview of the document; there is no PDF to
|
||||
// generate first — the final approval is what produces it.
|
||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
|
||||
setNeedsGenerateOpen(true);
|
||||
return;
|
||||
}
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
@@ -71,7 +62,7 @@ export function ContractApprovalStepsCard({
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ stepId: pendingStep.id },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
@@ -192,46 +183,6 @@ export function ContractApprovalStepsCard({
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={needsGenerateOpen}
|
||||
onClose={() => setNeedsGenerateOpen(false)}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={700}>Generate the contract first</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
The contract document for{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
has not been generated yet. Approvers must review the generated
|
||||
document before it can be approved.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Use{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
Generate contract
|
||||
</Text>{" "}
|
||||
in the Staff actions panel — edit the articles first if needed — then
|
||||
return here to approve.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileCheck size={16} />}
|
||||
onClick={() => setNeedsGenerateOpen(false)}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
|
||||
@@ -116,7 +116,9 @@ export function ContractDocumentEditorModal({
|
||||
}
|
||||
}, [mode, validityDays, validityOptions]);
|
||||
|
||||
const locked = mode === "edit" && Boolean(draft?.locked);
|
||||
// Editing rights belong to the approver whose turn it is, so the server
|
||||
// decides per-caller — the client cannot derive this from the contract alone.
|
||||
const locked = mode === "edit" && !draft?.editableByMe;
|
||||
|
||||
const moveArticle = (index: number, delta: number) => {
|
||||
setArticles((prev) => {
|
||||
@@ -215,7 +217,9 @@ export function ContractDocumentEditorModal({
|
||||
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."
|
||||
? draft?.nextApproverRole
|
||||
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
|
||||
: "This document can no longer be edited — the contract has advanced beyond approval."
|
||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, Group, Loader, Modal, Text } from "@mantine/core";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
interface ContractPreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
contractId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview of the contract document. Renders server-side HTML, not the
|
||||
* stored PDF — the PDF is only produced once the final approver approves, so
|
||||
* before that this is the document. Served in an iframe so the contract's own
|
||||
* styles stay sandboxed away from the app.
|
||||
*/
|
||||
export function ContractPreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
contractId,
|
||||
}: ContractPreviewModalProps) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["contracts", contractId, "contract-view"],
|
||||
queryFn: () => contractsService.getContractView(contractId),
|
||||
enabled: opened,
|
||||
// The document changes as approvers edit it, so never serve a stale render.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title="Contract document preview"
|
||||
>
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
mb="sm"
|
||||
p="xs"
|
||||
>
|
||||
<Text size="xs">
|
||||
Draft preview. The PDF is generated automatically once the final
|
||||
approver approves.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group gap="xs" py="xl" justify="center">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Rendering document…
|
||||
</Text>
|
||||
</Group>
|
||||
) : isError || !data?.html ? (
|
||||
<Text size="sm" c="red">
|
||||
The document could not be rendered. Check that the contract has a
|
||||
template and try again.
|
||||
</Text>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={data.html}
|
||||
title="Contract document preview"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "70vh",
|
||||
border: "none",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
|
||||
interface ContractRevisionTimelineProps {
|
||||
contractId: string;
|
||||
}
|
||||
|
||||
type Change = Freight.IContractDocumentChange;
|
||||
|
||||
/** Badge colour + verb per change kind, so a revision reads at a glance. */
|
||||
const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> = {
|
||||
ARTICLE_ADDED: { color: "green", label: "Added" },
|
||||
ARTICLE_REMOVED: { color: "red", label: "Removed" },
|
||||
ARTICLE_RENAMED: { color: "violet", label: "Renamed" },
|
||||
ARTICLE_BODY_CHANGED: { color: "blue", label: "Edited" },
|
||||
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
||||
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
||||
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
||||
};
|
||||
|
||||
/** What the change applies to — an article title, or the document itself. */
|
||||
function changeSubject(change: Change): string {
|
||||
switch (change.kind) {
|
||||
case "DOCUMENT_TITLE_CHANGED":
|
||||
return change.fromTitle
|
||||
? `“${change.fromTitle}” → “${change.title}”`
|
||||
: change.title;
|
||||
case "WHEREAS_CHANGED": {
|
||||
const parts: string[] = [];
|
||||
if (change.added) parts.push(`+${change.added}`);
|
||||
if (change.removed) parts.push(`−${change.removed}`);
|
||||
return parts.join(" ") || "changed";
|
||||
}
|
||||
case "ARTICLE_RENAMED":
|
||||
return `“${change.fromTitle}” → “${change.title}”`;
|
||||
case "ARTICLE_REORDERED":
|
||||
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
||||
default:
|
||||
return change.title;
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit trail of edits to the contract document. The document stays editable
|
||||
* through the approval chain, so this is the record of who changed what.
|
||||
*/
|
||||
export function ContractRevisionTimeline({
|
||||
contractId,
|
||||
}: ContractRevisionTimelineProps) {
|
||||
const { data: revisions, isLoading } = useQuery({
|
||||
queryKey: ["contracts", contractId, "document-revisions"],
|
||||
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard icon={History} title="Document history">
|
||||
{isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading history…
|
||||
</Text>
|
||||
</Group>
|
||||
) : !revisions?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No edits recorded yet. Changes made to the contract articles during
|
||||
approval will appear here.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline
|
||||
active={revisions.length}
|
||||
bulletSize={18}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<Timeline.Item
|
||||
key={revision.id}
|
||||
title={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{revision.actorRole ?? "Staff"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatWhen(revision.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap={6} mt={4}>
|
||||
{revision.summary && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{revision.summary}
|
||||
</Text>
|
||||
)}
|
||||
{revision.changes.map((change, index) => {
|
||||
const style = CHANGE_STYLES[change.kind];
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={style?.color ?? "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{style?.label ?? change.kind}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,10 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
function fmt(n: number): string {
|
||||
// A capacity axis can be null when the schedule's locomotive has no limit
|
||||
// configured for it — render "—" instead of crashing on toFixed.
|
||||
function fmt(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||||
}
|
||||
|
||||
@@ -43,13 +46,22 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||
}
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={capacity.wagons == null ? "gray" : capacity.wagons > 0 ? "teal" : "red"}
|
||||
>
|
||||
{fmt(capacity.wagons)} wagons free
|
||||
</Badge>
|
||||
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={capacity.weightTons == null ? "gray" : capacity.weightTons > 0 ? "teal" : "red"}
|
||||
>
|
||||
{fmt(capacity.weightTons)} t free
|
||||
</Badge>
|
||||
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={capacity.lengthMeters == null ? "gray" : capacity.lengthMeters > 0 ? "teal" : "red"}
|
||||
>
|
||||
{fmt(capacity.lengthMeters)} m free
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
const availableCount = availableWagons.length;
|
||||
const assignedCount = assignedWagons.length;
|
||||
const otherCount = otherWagons.length;
|
||||
// Split "Other" so a coupled wagon is visible as such. The Available/Assigned
|
||||
// buckets deliberately count only UNCOUPLED wagons (see above), so a yard
|
||||
// holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" —
|
||||
// accurate for shunting, but unreadable unless the other 53 are named.
|
||||
const onTrainCount = useMemo(
|
||||
() => matching.filter((w) => w.trainId != null).length,
|
||||
[matching],
|
||||
);
|
||||
|
||||
const destinationYardOptions = useMemo(
|
||||
() =>
|
||||
@@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<LegendDot color="teal" label="Available" value={availableCount} />
|
||||
<LegendDot color="blue" label="Assigned" value={assignedCount} />
|
||||
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
|
||||
<LegendDot color="teal" label="Available in yard" value={availableCount} />
|
||||
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
|
||||
{onTrainCount > 0 ? (
|
||||
<LegendDot color="gray" label="On train" value={onTrainCount} />
|
||||
) : null}
|
||||
{otherCount - onTrainCount > 0 ? (
|
||||
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Group, Loader, Table, Text } from "@mantine/core";
|
||||
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
/**
|
||||
* The trucks carrying one booking's cargo, and which containers ride each.
|
||||
*
|
||||
* A self-haul booking can have several trucks, each with 1–2 containers, but
|
||||
* the inventory table has one row per inventory item — so which container sits
|
||||
* on which truck was never visible without opening a document. Fetched lazily:
|
||||
* only an expanded row costs a request.
|
||||
*/
|
||||
export function TruckBreakdownRow({
|
||||
bookingId,
|
||||
colSpan,
|
||||
}: {
|
||||
bookingId: string;
|
||||
colSpan: number;
|
||||
}) {
|
||||
const { data: trucks = [], isLoading } = useQuery({
|
||||
queryKey: ["booking-customer-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
|
||||
{isLoading ? (
|
||||
<Group gap="xs" py="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading trucks…
|
||||
</Text>
|
||||
</Group>
|
||||
) : trucks.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" py="xs">
|
||||
No customer trucks assigned to this booking.
|
||||
</Text>
|
||||
) : (
|
||||
<Table verticalSpacing={4} withRowBorders={false}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
<Text size="xs" c="dimmed">
|
||||
Truck
|
||||
</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text size="xs" c="dimmed">
|
||||
Driver
|
||||
</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text size="xs" c="dimmed">
|
||||
Type
|
||||
</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text size="xs" c="dimmed">
|
||||
Containers
|
||||
</Text>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Text size="xs" c="dimmed">
|
||||
Status
|
||||
</Text>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trucks.map((truck) => (
|
||||
<Table.Tr key={truck.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
{truck.plateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{truck.driverName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{truck.truckType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* Bulk trucks carry loose tonnage, not containers. */}
|
||||
<Text size="xs">
|
||||
{truck.containers?.length
|
||||
? truck.containers.map((c) => c.containerNumber).join(", ")
|
||||
: "Bulk"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={truck.arrivedAt ? "edr-green" : "gray"}
|
||||
>
|
||||
{truck.arrivedAt
|
||||
? `Arrived ${new Date(truck.arrivedAt).toLocaleString()}`
|
||||
: "Not arrived"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
interface WarehouseInfoCardProps {
|
||||
bookingId: string;
|
||||
bookingReference?: string;
|
||||
/**
|
||||
* Booking payment status. Export cargo is received into the warehouse only
|
||||
* after the booking is paid — receiving an unpaid booking starts storage and
|
||||
* GRN against cargo the customer has not settled. Optional so existing callers
|
||||
* that do not have the booking to hand keep their current behaviour.
|
||||
*/
|
||||
paymentStatus?: string | null;
|
||||
/**
|
||||
* IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo
|
||||
* arrives OFF a train, so blocking its receive would strand cargo already at
|
||||
* the yard.
|
||||
*/
|
||||
tradeDirection?: string | null;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
@@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||
export function WarehouseInfoCard({
|
||||
bookingId,
|
||||
bookingReference,
|
||||
paymentStatus,
|
||||
tradeDirection,
|
||||
}: WarehouseInfoCardProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
@@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
||||
const wagon = scheduleView?.wagon;
|
||||
const isLoadedOrDispatched =
|
||||
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
||||
// Export only, and only when we were actually told the status — an absent prop
|
||||
// means the caller cannot answer, and guessing "unpaid" would disable a valid
|
||||
// action. Mirrors the server guard on receive().
|
||||
const awaitingPayment =
|
||||
tradeDirection?.toUpperCase() === 'EXPORT' &&
|
||||
paymentStatus != null &&
|
||||
paymentStatus.toUpperCase() !== 'PAID';
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
@@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
label="This booking is already received at the warehouse"
|
||||
disabled={!latest}
|
||||
label={
|
||||
latest
|
||||
? 'This booking is already received at the warehouse'
|
||||
: 'This booking is not paid yet — cargo can only be received once payment is settled'
|
||||
}
|
||||
disabled={!latest && !awaitingPayment}
|
||||
withArrow
|
||||
>
|
||||
{/* span wrapper so the tooltip still fires on the disabled button */}
|
||||
@@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
fullWidth
|
||||
disabled={Boolean(latest)}
|
||||
disabled={Boolean(latest) || awaitingPayment}
|
||||
>
|
||||
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { Fragment, useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { TruckBreakdownRow } from './TruckBreakdownRow';
|
||||
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
@@ -120,6 +121,16 @@ export function WarehouseInventoryTable({
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const selectable = Boolean(onToggleSelect);
|
||||
// Bookings whose truck breakdown is open. Expanded rows fetch on demand, so a
|
||||
// closed table costs nothing extra.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const toggleExpanded = (bookingId: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(bookingId)) next.delete(bookingId);
|
||||
else next.add(bookingId);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -144,6 +155,8 @@ export function WarehouseInventoryTable({
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
{/* Expander for the per-truck breakdown. */}
|
||||
<Table.Th w={32} />
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
@@ -174,8 +187,16 @@ export function WarehouseInventoryTable({
|
||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||
const handoverReference = handoverDocumentReference(item);
|
||||
|
||||
// Only offer the breakdown where there is one: a plate means at
|
||||
// least one customer truck is on the booking.
|
||||
const hasCustomerTrucks = Boolean(
|
||||
item.bookingId && item.booking?.customerTruckPlateNumber?.trim(),
|
||||
);
|
||||
const isExpanded = Boolean(item.bookingId && expanded.has(item.bookingId));
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
<Fragment key={item.id}>
|
||||
<Table.Tr>
|
||||
{selectable && (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
@@ -185,6 +206,23 @@ export function WarehouseInventoryTable({
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
{hasCustomerTrucks ? (
|
||||
<Tooltip
|
||||
label={isExpanded ? 'Hide trucks' : 'Show which containers ride which truck'}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label={isExpanded ? 'Hide trucks' : 'Show trucks'}
|
||||
onClick={() => toggleExpanded(item.bookingId as string)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{item.bookingReference || item.booking?.reference || item.bookingId ? (
|
||||
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
|
||||
@@ -309,6 +347,15 @@ export function WarehouseInventoryTable({
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isExpanded && item.bookingId ? (
|
||||
<TruckBreakdownRow
|
||||
bookingId={item.bookingId}
|
||||
// Expander + every data column + actions, plus the checkbox
|
||||
// when the table is selectable.
|
||||
colSpan={selectable ? 14 : 13}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -127,10 +127,6 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
|
||||
GOVERNMENT_EXPEDITE: (id: string) => `/bookings/${id}/government-expedite`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/bookings/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
`/bookings/${id}/approval-steps/${stepId}/reject`,
|
||||
CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||
@@ -181,6 +177,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
|
||||
CONTRACT_DOCUMENT_REVISIONS: (id: string) =>
|
||||
`/contracts/${id}/document/revisions`,
|
||||
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
|
||||
`/contracts/${id}/document/articles`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
@@ -441,6 +439,7 @@ export const URL_CONSTANTS = {
|
||||
APPROVAL_RULES: "/approval-rules",
|
||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
|
||||
},
|
||||
RATE_MATRIX: {
|
||||
BASE: "/api/rate-matrices",
|
||||
@@ -495,6 +494,7 @@ export const URL_CONSTANTS = {
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
TRUCKS_ON_SITE: "/warehouse-inventory/trucks-on-site",
|
||||
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
|
||||
`/warehouse-inventory/throughput?granularity=${granularity}`,
|
||||
DWELL_STATS: "/warehouse-inventory/dwell-stats",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
|
||||
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||
|
||||
export interface ApprovalProgressSummary {
|
||||
label: string;
|
||||
detail: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Compact approval chain summary for list rows and badges. */
|
||||
export function formatApprovalProgress(
|
||||
status: BookingStatus | string,
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): ApprovalProgressSummary {
|
||||
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (status === "SUBMITTED") {
|
||||
return {
|
||||
label: "Awaiting accept",
|
||||
detail: "Staff must accept intake",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status === "PENDING_APPROVAL" ||
|
||||
status === "APPROVED_PENDING_SIGNATURE"
|
||||
) {
|
||||
return {
|
||||
label: "No steps",
|
||||
detail: "Approval chain not started",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"PAID",
|
||||
"COMPLETED",
|
||||
].includes(status)
|
||||
) {
|
||||
return {
|
||||
label: "Approved",
|
||||
detail: "Internal approval complete",
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
return { label: "—", detail: "", complete: false };
|
||||
}
|
||||
|
||||
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||
const total = sorted.length;
|
||||
const next = getNextPendingApprovalStep(sorted);
|
||||
|
||||
if (!next && approved === total) {
|
||||
return {
|
||||
label: `${approved}/${total} done`,
|
||||
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||
complete: approved === total,
|
||||
};
|
||||
}
|
||||
@@ -14,18 +14,12 @@ import {
|
||||
hasPermission,
|
||||
isFreightApprovalAdmin,
|
||||
} from "@/lib/permissions";
|
||||
import type {
|
||||
BookingApprovalStep,
|
||||
BookingDetail,
|
||||
BookingStatus,
|
||||
} from "@/types/booking";
|
||||
import type { BookingDetail, BookingStatus } from "@/types/booking";
|
||||
|
||||
export type BookingActionId =
|
||||
| "accept"
|
||||
| "requestChanges"
|
||||
| "reject"
|
||||
| "approve"
|
||||
| "rejectApproval"
|
||||
| "viewContract"
|
||||
| "signContractStaff"
|
||||
| "reviewClearance"
|
||||
@@ -62,7 +56,6 @@ export type BookingActionContext = Pick<
|
||||
BookingDetail,
|
||||
| "status"
|
||||
| "paymentCurrency"
|
||||
| "approvalSteps"
|
||||
| "reference"
|
||||
| "schedulingStatus"
|
||||
| "customsClearingEnabled"
|
||||
@@ -86,39 +79,6 @@ export function canAllocateBooking(
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextPendingApprovalStep(
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): BookingApprovalStep | undefined {
|
||||
if (!steps?.length) return undefined;
|
||||
return [...steps]
|
||||
.sort((a, b) => a.stepOrder - b.stepOrder)
|
||||
.find((s) => s.status === "PENDING");
|
||||
}
|
||||
|
||||
function approvalActions(
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): BookingActionDef[] {
|
||||
const next = getNextPendingApprovalStep(steps);
|
||||
if (!next) return [];
|
||||
return [
|
||||
buildApproveActionForStep(next),
|
||||
{
|
||||
id: "rejectApproval",
|
||||
label: "Reject approval",
|
||||
shortLabel: "Reject",
|
||||
description: "Reject at the current approval step",
|
||||
confirmTitle: "Reject at approval step?",
|
||||
confirmDescription:
|
||||
"The booking will be marked rejected. This action cannot be undone from the UI.",
|
||||
variant: "destructive",
|
||||
icon: XCircle,
|
||||
input: "reason",
|
||||
inputLabel: "Rejection reason",
|
||||
inputPlaceholder: "Explain why this booking is rejected…",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
||||
{
|
||||
id: "accept",
|
||||
@@ -232,7 +192,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
accept: FREIGHT_PERMS.bookings.staffAccept,
|
||||
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||
reject: FREIGHT_PERMS.bookings.reject,
|
||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||
viewContract: FREIGHT_PERMS.bookings.view,
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -244,55 +203,12 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
const approvePermissionForRole = (role: string): string | undefined => {
|
||||
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
|
||||
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
|
||||
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** True when this step is the current pending step and the user may approve it. */
|
||||
export function canActOnApprovalStep(
|
||||
user: AuthUser | null | undefined,
|
||||
step: BookingApprovalStep,
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): boolean {
|
||||
if (step.status !== "PENDING") return false;
|
||||
const next = getNextPendingApprovalStep(steps);
|
||||
if (!next || next.id !== step.id) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
const perm = approvePermissionForRole(step.requiredRole);
|
||||
return perm ? hasPermission(user, perm) : false;
|
||||
}
|
||||
|
||||
export function buildApproveActionForStep(
|
||||
step: BookingApprovalStep,
|
||||
): BookingActionDef {
|
||||
return {
|
||||
id: "approve",
|
||||
label: `Approve (${step.requiredRole})`,
|
||||
shortLabel: "Approve",
|
||||
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
|
||||
confirmTitle: `Approve as ${step.requiredRole}?`,
|
||||
confirmDescription:
|
||||
"This records your approval and advances the booking to the next step in the chain.",
|
||||
variant: "default",
|
||||
icon: Check,
|
||||
primary: true,
|
||||
};
|
||||
}
|
||||
|
||||
function filterActionsByUser(
|
||||
actions: BookingActionDef[],
|
||||
user: AuthUser | null | undefined,
|
||||
approvalSteps?: BookingApprovalStep[] | null,
|
||||
): BookingActionDef[] {
|
||||
if (!user) return [];
|
||||
const next = getNextPendingApprovalStep(approvalSteps);
|
||||
return actions.filter((action) => {
|
||||
if (action.id === "approve" && next) {
|
||||
return canActOnApprovalStep(user, next, approvalSteps);
|
||||
}
|
||||
const perm = ACTION_PERMISSION[action.id];
|
||||
return perm ? hasPermission(user, perm) : true;
|
||||
});
|
||||
@@ -303,7 +219,7 @@ export function getBookingActions(
|
||||
ctx: BookingActionContext,
|
||||
user?: AuthUser | null,
|
||||
): BookingActionDef[] {
|
||||
const { status, approvalSteps } = ctx;
|
||||
const { status } = ctx;
|
||||
|
||||
let actions: BookingActionDef[];
|
||||
|
||||
@@ -313,8 +229,6 @@ export function getBookingActions(
|
||||
break;
|
||||
case "PENDING_APPROVAL":
|
||||
case "APPROVED_PENDING_SIGNATURE":
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
@@ -370,7 +284,7 @@ export function getBookingActions(
|
||||
}
|
||||
|
||||
if (user === undefined) return actions;
|
||||
return filterActionsByUser(actions, user, approvalSteps);
|
||||
return filterActionsByUser(actions, user);
|
||||
}
|
||||
|
||||
/** Opens contract page without confirmation dialog. */
|
||||
@@ -392,7 +306,6 @@ export function listRowHasActions(
|
||||
row: {
|
||||
status: BookingStatus;
|
||||
paymentCurrency: string;
|
||||
approvalSteps?: BookingApprovalStep[] | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
},
|
||||
user?: AuthUser | null,
|
||||
@@ -402,7 +315,6 @@ export function listRowHasActions(
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: "",
|
||||
approvalSteps: row.approvalSteps ?? undefined,
|
||||
schedulingStatus: row.status,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
contractId: booking.contractId ?? null,
|
||||
approvalSteps: booking.approvalSteps,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||
@@ -46,6 +45,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
|
||||
customsClearingEnabled: booking.customsClearingEnabled ?? false,
|
||||
bookingKind:
|
||||
booking.contractKind === "GENERAL" ? "GENERAL_CONTRACT" : "ONE_TIME",
|
||||
createdAt: booking.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,40 +87,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
api.bookings.approveStep.call({
|
||||
id: bookingId,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}),
|
||||
onSuccess: (data) => onSuccess(data, "Approval step completed"),
|
||||
onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
|
||||
});
|
||||
|
||||
const rejectStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
reason,
|
||||
}: {
|
||||
stepId: string;
|
||||
reason: string;
|
||||
}) =>
|
||||
api.bookings.rejectStep.call({
|
||||
id: bookingId,
|
||||
stepId,
|
||||
reason,
|
||||
}),
|
||||
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
|
||||
onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
|
||||
});
|
||||
|
||||
const generateContract = useMutation({
|
||||
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
|
||||
onSuccess: (data) => onSuccess(data, "Contract generated"),
|
||||
@@ -167,8 +133,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
staffAccept.isPending ||
|
||||
requestChanges.isPending ||
|
||||
staffReject.isPending ||
|
||||
approveStep.isPending ||
|
||||
rejectStep.isPending ||
|
||||
generateContract.isPending ||
|
||||
signContract.isPending ||
|
||||
payBooking.isPending ||
|
||||
@@ -182,8 +146,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
requestChanges,
|
||||
staffReject,
|
||||
reviewOperation,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
signContract,
|
||||
payBooking,
|
||||
|
||||
@@ -122,6 +122,15 @@ export function useContractMutations(contractId: string) {
|
||||
const onSuccess = (data: { id: string }, message: string) => {
|
||||
toast.success(message);
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
// Approving or editing can change who holds document-editing rights (it
|
||||
// passes to the next approver), and edits add revisions — so both the draft
|
||||
// and the history are refreshed on every contract mutation.
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", data.id, "document-draft"],
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", data.id, "document-revisions"],
|
||||
});
|
||||
};
|
||||
|
||||
const staffAccept = useMutation({
|
||||
@@ -160,21 +169,16 @@ export function useContractMutations(contractId: string) {
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||
// The server derives the required role from the step itself, so the client
|
||||
// does not send one.
|
||||
mutationFn: ({ stepId }: { stepId: string }) =>
|
||||
contractsService.approveStep({ id: contractId, stepId }),
|
||||
onSuccess: (data) => {
|
||||
// The document is generated at the accept stage and reviewed during
|
||||
// approval, so the final approval moves the contract straight to
|
||||
// CONTRACT_READY on the server — no client-side generate call here.
|
||||
// The final approval is what generates the PDF and moves the contract to
|
||||
// CONTRACT_READY — before that approvers review a live preview.
|
||||
const message =
|
||||
data.status === "CONTRACT_READY"
|
||||
? "Final approval complete — contract ready to sign"
|
||||
? "Final approval complete — contract generated and ready to sign"
|
||||
: "Approval step completed";
|
||||
onSuccess(data, message);
|
||||
},
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type SubmitPriorityRuleChangePayload,
|
||||
type SubmitRateChangePayload,
|
||||
} from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
LEGACY_APPROVAL_ROLES,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type {
|
||||
RuleEngineRecord,
|
||||
RuleEngineResourceSlug,
|
||||
@@ -155,6 +158,33 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
* rule still stored against one of them renders its label instead of an empty
|
||||
* select; a position type that reuses one of those values wins the dedupe.
|
||||
*/
|
||||
export const useApprovalRoleOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("approval-rules", {
|
||||
positionTypes: true,
|
||||
}),
|
||||
queryFn: () => ruleEngineService.getApprovalPositionTypes(),
|
||||
enabled,
|
||||
select: (rows): { label: string; value: string }[] => {
|
||||
const byValue = new Map<string, { label: string; value: string }>();
|
||||
for (const row of rows) {
|
||||
const value = String(row?.value ?? "").trim();
|
||||
if (!value) continue;
|
||||
byValue.set(value, { label: String(row.label ?? "").trim() || value, value });
|
||||
}
|
||||
for (const legacy of LEGACY_APPROVAL_ROLES) {
|
||||
if (!byValue.has(legacy.value)) byValue.set(legacy.value, legacy);
|
||||
}
|
||||
return [...byValue.values()];
|
||||
},
|
||||
});
|
||||
|
||||
/** A yard option that remembers its country, so callers can filter by leg. */
|
||||
export interface YardOption {
|
||||
label: string;
|
||||
|
||||
@@ -154,6 +154,15 @@ export function useWarehouseOpsStats() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Trucks in the yard right now — refreshes with the rest of the ops widgets. */
|
||||
export function useTrucksOnSite() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'trucks-on-site'],
|
||||
queryFn: () => warehouseService.trucksOnSite().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;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
BookingApprovalCard,
|
||||
BookingContainersCard,
|
||||
BookingDetailToolbar,
|
||||
BookingDocumentsCard,
|
||||
@@ -75,29 +74,6 @@ const BookingDetailPage = () => {
|
||||
containerType: { label: "20FT Standard", sizeFt: 20 },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
{
|
||||
id: "1",
|
||||
stepOrder: 1,
|
||||
requiredRole: "LINE_STAFF",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T11:00:00Z",
|
||||
},
|
||||
// {
|
||||
// id: "2",
|
||||
// stepOrder: 2,
|
||||
// requiredRole: "DIRECTOR",
|
||||
// status: "APPROVED",
|
||||
// actionedAt: "2026-06-05T13:30:00Z",
|
||||
// },
|
||||
{
|
||||
id: "3",
|
||||
stepOrder: 3,
|
||||
requiredRole: "CEO",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T15:45:00Z",
|
||||
},
|
||||
],
|
||||
reviewNotes: [
|
||||
{
|
||||
id: "1",
|
||||
@@ -119,11 +95,6 @@ const BookingDetailPage = () => {
|
||||
],
|
||||
};
|
||||
|
||||
const approvalSteps = booking.approvalSteps ?? [];
|
||||
const approvedCount = approvalSteps.filter(
|
||||
(s) => s.status === "APPROVED",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
@@ -136,13 +107,6 @@ const BookingDetailPage = () => {
|
||||
{ label: booking.reference },
|
||||
]}
|
||||
/>
|
||||
{/*
|
||||
<BookingDetailHeader
|
||||
booking={booking}
|
||||
approvedCount={approvedCount}
|
||||
totalSteps={totalSteps}
|
||||
/> */}
|
||||
|
||||
<BookingLifecycleStepper status={booking.status} />
|
||||
|
||||
<Grid>
|
||||
@@ -164,10 +128,6 @@ const BookingDetailPage = () => {
|
||||
await allocateMutation.mutateAsync({ allocations });
|
||||
}}
|
||||
/>
|
||||
<BookingApprovalCard
|
||||
steps={approvalSteps}
|
||||
approvedCount={approvedCount}
|
||||
/>
|
||||
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
@@ -130,10 +129,6 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
|
||||
// bookings are handled in the Global Logistics clearance queue instead.
|
||||
const showClearanceTab =
|
||||
@@ -265,6 +260,8 @@ export default function BookingRequestDetailPage() {
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
tradeDirection={booking.tradeDirection}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
@@ -283,9 +280,6 @@ export default function BookingRequestDetailPage() {
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
@@ -33,7 +32,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
@@ -60,10 +58,10 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** The two booking-kind tabs: one-time vs general-contract bookings. */
|
||||
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */
|
||||
type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
|
||||
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
|
||||
const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
|
||||
{ value: "ONE_TIME", label: "One-time booking" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
||||
];
|
||||
@@ -128,9 +126,9 @@ export default function BookingRequestsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter controls (empty/null = "all").
|
||||
// Booking kind is a filter now — one list holds both kinds (null = "all").
|
||||
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
@@ -158,9 +156,9 @@ export default function BookingRequestsPage() {
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
// React Query cache key per kind selection ("ALL" when unfiltered).
|
||||
tab: kindFilter ?? "ALL",
|
||||
...(kindFilter ? { bookingType: kindFilter } : {}),
|
||||
// Server-side free-text search (booking ref, customer, contract ref).
|
||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
@@ -182,7 +180,7 @@ export default function BookingRequestsPage() {
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
kindTab,
|
||||
kindFilter,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
@@ -226,6 +224,7 @@ export default function BookingRequestsPage() {
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const activeFilterCount =
|
||||
(kindFilter ? 1 : 0) +
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
@@ -237,6 +236,7 @@ export default function BookingRequestsPage() {
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setKindFilter(null);
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
@@ -327,6 +327,23 @@ export default function BookingRequestsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookingKind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
|
||||
return (
|
||||
<div className="py-1">
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -376,13 +393,6 @@ export default function BookingRequestsPage() {
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Approval</span>
|
||||
),
|
||||
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
@@ -482,22 +492,6 @@ export default function BookingRequestsPage() {
|
||||
/>
|
||||
*/}
|
||||
|
||||
<Tabs
|
||||
value={kindTab}
|
||||
onChange={(value) => {
|
||||
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{BOOKING_KIND_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
@@ -535,6 +529,18 @@ export default function BookingRequestsPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All booking types"
|
||||
data={BOOKING_KIND_OPTIONS}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter((v as BookingKind | null) ?? null);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 190 }}
|
||||
/>
|
||||
<MultiSelect
|
||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
||||
data={STATUS_OPTIONS}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Scoped to .edr-booking-requests-table — the DataTable container div on the
|
||||
* backoffice booking-requests list only; no other DataTable is affected.
|
||||
* Mirrors the portal's /bookings table (bookings-table.css): horizontal
|
||||
* scroll on the container, a sticky header row, and a sticky/shadowed
|
||||
* action column — with a compact 40–60px column width band (content
|
||||
* beyond that is clipped with an ellipsis) instead of the portal's
|
||||
* content-sized columns.
|
||||
*/
|
||||
.edr-booking-requests-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* width: max-content — the table is exactly as wide as its columns need,
|
||||
* never squeezed to fit the viewport; the container scrolls instead.
|
||||
* min-width: 100% keeps it filling the card when content is narrow.
|
||||
*/
|
||||
.edr-booking-requests-table table {
|
||||
table-layout: auto;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Compact column band: 40px floor, 60px ceiling, ellipsis past that. */
|
||||
.edr-booking-requests-table th,
|
||||
.edr-booking-requests-table td:not([colspan]) {
|
||||
min-width: 40px;
|
||||
max-width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Full-width rows (loading skeleton / error / empty state) span every
|
||||
* column via colspan — leave their sizing and wrapping alone.
|
||||
*/
|
||||
.edr-booking-requests-table td[colspan] {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Sticky header row. */
|
||||
.edr-booking-requests-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fixed, sticky action column. Overrides the inline width DataTable stamps
|
||||
* from tanstack's column size (`size: 140` on the actions column) — hence
|
||||
* !important. `:not([colspan])` keeps the full-width error/empty rows out.
|
||||
*/
|
||||
.edr-booking-requests-table th:last-child,
|
||||
.edr-booking-requests-table td:last-child:not([colspan]) {
|
||||
width: 60px !important;
|
||||
min-width: 60px;
|
||||
max-width: 60px;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky cells sit above the scrolling ones, so they need their own opaque
|
||||
* background or the columns underneath show through.
|
||||
*/
|
||||
.edr-booking-requests-table td:last-child:not([colspan]) {
|
||||
background: #f5f8fb;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
||||
.edr-booking-requests-table tbody tr:hover td:last-child:not([colspan]) {
|
||||
background: var(--accent, #f4fbf8);
|
||||
}
|
||||
|
||||
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
||||
.edr-booking-requests-table th:last-child {
|
||||
background: #f4f7fa;
|
||||
z-index: 3;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
@@ -493,6 +494,7 @@ export default function ContractRequestDetailPage() {
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractRevisionTimeline contractId={contract.id} />
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
|
||||
@@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [
|
||||
{ label: "Out of service", value: "OUT_OF_SERVICE" },
|
||||
];
|
||||
|
||||
// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here:
|
||||
// staff still need to search for assigned wagons.
|
||||
const WAGON_STATUS_OPTIONS = [
|
||||
{ label: "Available", value: Freight.WagonStatus.Available },
|
||||
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
||||
@@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [
|
||||
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
||||
];
|
||||
|
||||
// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted
|
||||
// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a
|
||||
// train, never by editing it directly. Setting it by hand produced wagons that
|
||||
// claim to be assigned while coupled to nothing, which the yard workspace then
|
||||
// counts as in-yard stock.
|
||||
const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter(
|
||||
(o) => o.value !== Freight.WagonStatus.Assigned,
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
||||
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS },
|
||||
{ name: "notes", label: "Notes", type: "textarea" },
|
||||
],
|
||||
emptyValues: {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||
import {
|
||||
useApprovalChain,
|
||||
useApprovalRoleOptions,
|
||||
useCargoLeafOptions,
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
@@ -247,6 +248,13 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||
useYardOptions(usesYardField);
|
||||
const usesApprovalRoleField = Boolean(
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||
),
|
||||
);
|
||||
const { data: approvalRoleOptions, isLoading: approvalRoleOptionsLoading } =
|
||||
useApprovalRoleOptions(usesApprovalRoleField);
|
||||
|
||||
// Full rule list backing the auto-filled "min wagon count": the next range
|
||||
// always continues the chain for the selected type (per currency), so the
|
||||
@@ -321,6 +329,20 @@ const RuleEngineResourcePage = () => {
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
// Approval steps are configured against live IAM position types; until
|
||||
// they load, the static legacy list on the field config stands in so an
|
||||
// existing row's role still shows a label.
|
||||
if (field.name === "requiredRole" || field.name === "blocksRole") {
|
||||
if (!approvalRoleOptions) return field;
|
||||
const includeNone = field.name === "blocksRole";
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: includeNone
|
||||
? [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...approvalRoleOptions]
|
||||
: approvalRoleOptions,
|
||||
};
|
||||
}
|
||||
// Each end of the leg only offers yards in the country that end of the
|
||||
// trade actually sits in, so an import can't be configured as if it
|
||||
// started inland. Resolved per keystroke because the legal set changes
|
||||
@@ -336,7 +358,7 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, approvalRoleOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -765,7 +787,8 @@ const RuleEngineResourcePage = () => {
|
||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading)
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
positionLoading={createPositionLoading}
|
||||
|
||||
@@ -118,10 +118,16 @@ const YARD_COUNTRIES = [
|
||||
{ label: "Djibouti", value: "Djibouti" },
|
||||
];
|
||||
|
||||
const APPROVAL_ROLES = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
{ label: "CEO", value: "CEO" },
|
||||
/**
|
||||
* The three role strings the approval chain was hardcoded to before it was
|
||||
* driven by IAM position types. Kept only so rows still stored against them
|
||||
* render a readable label instead of a blank select — the live options come
|
||||
* from GET /approval-rules/position-types (see `useApprovalRoleOptions`).
|
||||
*/
|
||||
export const LEGACY_APPROVAL_ROLES = [
|
||||
{ label: "Line staff (legacy)", value: "LINE_STAFF" },
|
||||
{ label: "Director (legacy)", value: "DIRECTOR" },
|
||||
{ label: "CEO (legacy)", value: "CEO" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -718,7 +724,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Required role",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: APPROVAL_ROLES,
|
||||
// Replaced at render time with live IAM position types (+ legacy values).
|
||||
options: LEGACY_APPROVAL_ROLES,
|
||||
},
|
||||
{ name: "actionLabel", label: "Action label", type: "text", required: true },
|
||||
{
|
||||
@@ -726,7 +733,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Blocks role",
|
||||
type: "select",
|
||||
optional: true,
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
|
||||
// Replaced at render time with live IAM position types (+ legacy values).
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...LEGACY_APPROVAL_ROLES],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
// Ban, — used only by the commented-out "Cancel schedule" row action
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Eye,
|
||||
@@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||
// const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||
|
||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||
// API rejects them, so keep them out of the picker entirely.
|
||||
@@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
Booking window settings
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{/* Cancel schedule — hidden for now (frontend only; the
|
||||
cancelSchedule mutation is untouched). Restore by
|
||||
uncommenting.
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
@@ -487,6 +490,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
Cancel schedule
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
*/}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [navigate, cancel.isPending, cancel, toast]);
|
||||
}, [navigate, toast]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!routeId || !scheduleDate || !trainId) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||
import type { TruckOnSite } from "@/types/warehouse";
|
||||
|
||||
/**
|
||||
* Every truck inside the yard right now, across all bookings.
|
||||
*
|
||||
* The gate's question is "which trucks are here", not "which bookings have
|
||||
* trucks" — the ops dashboard could only count them, never open the list. Both
|
||||
* haulage paths appear because the same barrier handles both: a customer's own
|
||||
* truck and an EDR last-mile truck.
|
||||
*/
|
||||
|
||||
/** How long the truck has been on site — the number the gate actually chases. */
|
||||
function dwell(arrivedAt: string | null): string {
|
||||
if (!arrivedAt) return "—";
|
||||
const minutes = Math.floor((Date.now() - new Date(arrivedAt).getTime()) / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
||||
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
/** Long dwell means a truck is sitting at the gate — worth flagging, not hiding. */
|
||||
const LONG_DWELL_HOURS = 4;
|
||||
|
||||
function isLongDwell(arrivedAt: string | null): boolean {
|
||||
if (!arrivedAt) return false;
|
||||
return Date.now() - new Date(arrivedAt).getTime() > LONG_DWELL_HOURS * 3_600_000;
|
||||
}
|
||||
|
||||
function Rows({ rows }: { rows: TruckOnSite[] }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray">
|
||||
No trucks on site.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={980}>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Haulage</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>On site</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => (
|
||||
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.plateNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={row.source === "CUSTOMER" ? "blue" : "edr-green"}
|
||||
>
|
||||
{row.source === "CUSTOMER" ? "Customer" : "EDR"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.driverName ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.truckType ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.bookingReference ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customerName ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* Bulk trucks carry no containers — they haul loose tonnage. */}
|
||||
<Text size="sm">{row.containers ?? "Bulk"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isLongDwell(row.arrivedAt) ? (
|
||||
<Tooltip
|
||||
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
|
||||
withArrow
|
||||
>
|
||||
<Text size="sm" c="red" fw={600}>
|
||||
{dwell(row.arrivedAt)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm">{dwell(row.arrivedAt)}</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrucksOnSitePage() {
|
||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return trucks
|
||||
.filter((t) => source === "ALL" || t.source === source)
|
||||
.filter((t) =>
|
||||
!term
|
||||
? true
|
||||
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
|
||||
.some((field) => field?.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [trucks, source, search]);
|
||||
|
||||
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
|
||||
const edrCount = trucks.length - customerCount;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Trucks on site"
|
||||
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={source}
|
||||
onChange={(v) => setSource(v as typeof source)}
|
||||
data={[
|
||||
{ label: `All (${trucks.length})`, value: "ALL" },
|
||||
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
|
||||
{ label: `EDR (${edrCount})`, value: "EDR" },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={280}
|
||||
placeholder="Plate, driver, booking, container…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? <Text size="sm">Loading…</Text> : <Rows rows={rows} />}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -133,9 +133,7 @@ import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
BookingListFilter,
|
||||
bookingsService,
|
||||
type ApproveStepPayload,
|
||||
type PaginatedBookings,
|
||||
type RejectStepPayload,
|
||||
} from "./bookings.service";
|
||||
import { cargoTypesService } from "./cargo-types.service";
|
||||
import {
|
||||
@@ -2482,18 +2480,6 @@ export const api = {
|
||||
bookingsService.reviewOperation(id, decision, { note }),
|
||||
),
|
||||
|
||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||
"bookings",
|
||||
"approveStep",
|
||||
(payload) => bookingsService.approveStep(payload),
|
||||
),
|
||||
|
||||
rejectStep: endpoint<RejectStepPayload, BookingDetail>(
|
||||
"bookings",
|
||||
"rejectStep",
|
||||
(payload) => bookingsService.rejectStep(payload),
|
||||
),
|
||||
|
||||
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||
"bookings",
|
||||
"generateContract",
|
||||
|
||||
@@ -72,18 +72,6 @@ export interface BookingListSummary {
|
||||
tabs: BookingListSummaryTabs;
|
||||
}
|
||||
|
||||
export interface ApproveStepPayload {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}
|
||||
|
||||
export interface RejectStepPayload {
|
||||
id: string;
|
||||
stepId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ContractView {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
@@ -255,12 +243,6 @@ export const bookingsService = {
|
||||
...options,
|
||||
}),
|
||||
|
||||
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
||||
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
||||
|
||||
rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
|
||||
postBooking<BookingDetail>(B.REJECT_STEP(id, stepId), { reason }),
|
||||
|
||||
generateContract: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||
|
||||
|
||||
@@ -196,6 +196,14 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.IContractDocumentDraft;
|
||||
},
|
||||
|
||||
/** Audit trail of edits to this contract's document, newest first. */
|
||||
getContractDocumentRevisions: async (
|
||||
id: string,
|
||||
): Promise<Freight.IContractDocumentRevision[]> => {
|
||||
const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id));
|
||||
return unwrap(response.data) as Freight.IContractDocumentRevision[];
|
||||
},
|
||||
|
||||
/** Save this contract's edited document articles (never touches the templates). */
|
||||
updateContractDocument: async (
|
||||
id: string,
|
||||
@@ -214,18 +222,12 @@ export const contractsService = {
|
||||
reject: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||
|
||||
approveStep: ({
|
||||
id,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
|
||||
requiredRole,
|
||||
}),
|
||||
/**
|
||||
* Approve the next pending step. The server resolves the step's required role
|
||||
* and authorizes against it — the client never declares its own role.
|
||||
*/
|
||||
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
|
||||
|
||||
rejectStep: ({
|
||||
id,
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface SubmitRateChangePayload {
|
||||
update: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One selectable IAM position type, as returned by /approval-rules/position-types. */
|
||||
export interface ApprovalPositionType {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
@@ -368,6 +374,28 @@ export const ruleEngineService = {
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
/**
|
||||
* IAM position types that an approval step can require/block. Replaces the
|
||||
* old hardcoded LINE_STAFF/DIRECTOR/CEO triple — the chain is configured from
|
||||
* whatever positions IAM actually defines.
|
||||
*/
|
||||
getApprovalPositionTypes: async (): Promise<ApprovalPositionType[]> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_POSITION_TYPES,
|
||||
);
|
||||
const body = unwrap(response.data) as unknown;
|
||||
if (Array.isArray(body)) return body as ApprovalPositionType[];
|
||||
if (
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"data" in body &&
|
||||
Array.isArray((body as { data: unknown }).data)
|
||||
) {
|
||||
return (body as { data: ApprovalPositionType[] }).data;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ZoneOccupancy,
|
||||
TruckOnSite,
|
||||
WarehouseOpsStats,
|
||||
WarehouseThroughputPoint,
|
||||
WarehouseDwellStats,
|
||||
@@ -404,6 +405,9 @@ export const warehouseService = {
|
||||
),
|
||||
opsStats: () =>
|
||||
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
|
||||
/** Trucks inside the yard right now — the list behind the trucksOnSite figure. */
|
||||
trucksOnSite: () =>
|
||||
apiClient.get<TruckOnSite[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.TRUCKS_ON_SITE),
|
||||
throughput: (granularity: 'week' | 'month' | 'year') =>
|
||||
apiClient.get<WarehouseThroughputPoint[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
|
||||
|
||||
@@ -101,16 +101,6 @@ export interface BookingContainerLine {
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
export interface BookingApprovalStep {
|
||||
id: string;
|
||||
stepOrder: number;
|
||||
requiredRole: string;
|
||||
blocksRole?: string | null;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
|
||||
actionedAt?: string | null;
|
||||
remarks?: string | null;
|
||||
}
|
||||
|
||||
export interface BookingNextStep {
|
||||
action: string;
|
||||
description: string;
|
||||
@@ -219,7 +209,6 @@ export interface BookingDetail {
|
||||
cargoType?: BookingNamedRef;
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
files?: BookingFile[];
|
||||
cargoModifiers?: Array<{
|
||||
@@ -236,7 +225,6 @@ export interface BookingListRow {
|
||||
/** Needed to link the reference to the contract's detail page. */
|
||||
contractId?: string | null;
|
||||
customerLabel: string;
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
@@ -255,5 +243,11 @@ export interface BookingListRow {
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
/**
|
||||
* Derived booking kind for the list "Type" column. Mirrors the server's
|
||||
* bookingType filter: bookings under a GENERAL contract are general,
|
||||
* everything else is one-time.
|
||||
*/
|
||||
bookingKind?: "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -879,9 +879,12 @@ export interface CompositionRemovalEntry {
|
||||
// the train's remaining wagon/weight/length capacity.
|
||||
|
||||
export interface IntercityCapacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
// Each axis can be null when the schedule's train/locomotive has no limit
|
||||
// configured for it (e.g. no max length on the loco) — the API passes the
|
||||
// gap through rather than inventing a number.
|
||||
wagons: number | null;
|
||||
weightTons: number | null;
|
||||
lengthMeters: number | null;
|
||||
}
|
||||
|
||||
export interface IntercityBookingRow {
|
||||
|
||||
@@ -1121,6 +1121,24 @@ export interface WarehouseOpsStats {
|
||||
itemsAging: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One truck inside the yard. Both haulage paths appear here because the gate
|
||||
* handles both: `CUSTOMER` is the customer's own truck, `EDR` a last-mile truck.
|
||||
*/
|
||||
export interface TruckOnSite {
|
||||
source: "CUSTOMER" | "EDR";
|
||||
assignmentId: string;
|
||||
plateNumber: string | null;
|
||||
driverName: string | null;
|
||||
truckType: string | null;
|
||||
arrivedAt: string | null;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
/** Comma-separated container numbers; null for bulk. */
|
||||
containers: string | null;
|
||||
}
|
||||
|
||||
/** One bucket of the received-vs-dispatched throughput time series. */
|
||||
export interface WarehouseThroughputPoint {
|
||||
periodStart: string;
|
||||
|
||||
@@ -21,6 +21,9 @@ import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
|
||||
|
||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { BodyGrid } from "./components/layout";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractInfoCard } from "./components/ContractInfoCard";
|
||||
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
|
||||
import { PageHeader } from "./components/PageHeader";
|
||||
import { EstimateCard } from "./components/pricing";
|
||||
@@ -94,6 +97,10 @@ export function ChangesRequestedView({
|
||||
<>
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Your documents</CardTitle>
|
||||
@@ -143,6 +150,7 @@ export function ChangesRequestedView({
|
||||
chip="Not invoiced"
|
||||
/>
|
||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractInfoCard } from "./components/ContractInfoCard";
|
||||
import { CountChip, DocRow, IconSquare } from "./components/Documents";
|
||||
import { EstimateCard } from "./components/pricing";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
@@ -241,6 +244,10 @@ export function DraftBookingView({
|
||||
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
{/* Documents (uploadable) */}
|
||||
<SectionCard ref={documentsRef}>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -399,6 +406,7 @@ export function DraftBookingView({
|
||||
chip="Not invoiced"
|
||||
/>
|
||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { DocumentsTab } from "./components/DocumentsTab";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { ContractInfoCard } from "./components/ContractInfoCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { MileSummaryCard } from "./components/MileSummaryCard";
|
||||
@@ -271,6 +273,8 @@ export function ReadonlyBookingView({
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
@@ -300,6 +304,7 @@ export function ReadonlyBookingView({
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* `GET /api/bookings/:id` serializes the raw TypeORM `Booking` entity with its
|
||||
* relations attached (company, bookingContainers → units, shippingLine,
|
||||
* cargoType…). That's a strict superset of the `Freight.IBooking` DTO, which
|
||||
* doesn't declare these relations (and still lists a couple of fields —
|
||||
* `freightSubtype`, the string-enum `serviceType` — that the API never
|
||||
* actually sends). This augments the shared type with what the endpoint
|
||||
* really returns so the detail page can render it without unsafe casts.
|
||||
*/
|
||||
|
||||
export interface BookingContainerUnitDetail {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number | string;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
isReturn?: boolean;
|
||||
receivedToPort?: boolean;
|
||||
receivedAt?: string | null;
|
||||
grnNumber?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface BookingContainerLineDetail {
|
||||
id: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number | string;
|
||||
totalVgmTons: number | string;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
returnQuantity?: number;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | string | null;
|
||||
containerNumber?: string | null;
|
||||
containerType?: {
|
||||
code: string;
|
||||
label?: string | null;
|
||||
sizeFt?: number | null;
|
||||
isReefer?: boolean | null;
|
||||
} | null;
|
||||
units?: BookingContainerUnitDetail[];
|
||||
}
|
||||
|
||||
export interface BookingShippingLineDetail {
|
||||
code: string;
|
||||
label: string;
|
||||
showExtraFeeNotice?: boolean;
|
||||
}
|
||||
|
||||
export interface BookingCargoTypeDetail {
|
||||
code: string;
|
||||
cargoTypeName: string;
|
||||
unitOfMeasure?: string | null;
|
||||
}
|
||||
|
||||
/** The API's real (object) shape for the joined service-type relation. */
|
||||
export type BookingServiceTypeRef = NonNullable<Freight.IContract["serviceType"]>;
|
||||
|
||||
export type BookingDetail = Freight.IBooking & {
|
||||
/** Billed-to company relation, always joined on the detail endpoint. */
|
||||
company?: Freight.BookingRequestCompany | null;
|
||||
isGovernment?: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
/** Real container line-items (with per-unit numbers/seals/VGM). */
|
||||
bookingContainers?: BookingContainerLineDetail[] | null;
|
||||
shippingLine?: BookingShippingLineDetail | null;
|
||||
cargoType?: BookingCargoTypeDetail | null;
|
||||
cargoFreeText?: string | null;
|
||||
/** Joined paired-booking relation (consolidation partner), not just its id. */
|
||||
consolidationPartner?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `booking.serviceType` is declared as the legacy `"RAIL_ONLY" |
|
||||
* "RAIL_AND_FORWARDING"` string enum on `Freight.IBooking`, but the API
|
||||
* actually sends the joined ServiceType relation object (`{ code,
|
||||
* serviceName, includesFirstMile, includesLastMile, includesCustoms, … }`).
|
||||
* Read it through this helper instead of comparing directly — see
|
||||
* `serviceTypeLabel()` in `utils.ts`.
|
||||
*/
|
||||
export function rawServiceType(
|
||||
booking: BookingDetail,
|
||||
): string | BookingServiceTypeRef | null | undefined {
|
||||
return (booking as unknown as { serviceType?: string | BookingServiceTypeRef | null })
|
||||
.serviceType;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Box, Divider, Group, Text } from "@mantine/core";
|
||||
import { Building2, Landmark, Mail, MapPin, Phone, User } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function InfoRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={10} align="flex-start" wrap="nowrap" py={8}>
|
||||
<Box
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#F1F4F7",
|
||||
color: "#475569",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box miw={0} flex={1}>
|
||||
<Text fz="10.5px" fw={600} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.04em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={2} fz="13.5px" fw={700} c="#10202F" style={{ wordBreak: "break-word" }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer/company information billed on this booking — the joined
|
||||
* `company` relation the detail endpoint always returns (name, TIN, contact
|
||||
* details), which the previous UI never surfaced at all.
|
||||
*/
|
||||
export function CompanyInfoCard({ booking }: { booking: BookingDetail }) {
|
||||
const company = booking.company;
|
||||
if (!company) return null;
|
||||
|
||||
const contact = company.contactPersonName
|
||||
? company.contactPersonPhone
|
||||
? `${company.contactPersonName} · ${company.contactPersonPhone}`
|
||||
: company.contactPersonName
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb={4}>
|
||||
<CardTitle>Customer Information</CardTitle>
|
||||
{booking.isGovernment && (
|
||||
<Group
|
||||
component="span"
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#EAF1FE",
|
||||
border: "1px solid #CFDDFB",
|
||||
padding: "3px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#1E40AF",
|
||||
}}
|
||||
>
|
||||
<Landmark size={12} />
|
||||
Government
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="16px" fw={800} c="#10202F" mt={6}>
|
||||
{company.name || "—"}
|
||||
</Text>
|
||||
{booking.governmentInstitution && (
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={2}>
|
||||
{booking.governmentInstitution}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
|
||||
<Box>
|
||||
{company.tin && (
|
||||
<InfoRow icon={<Building2 size={15} />} label="TIN" value={company.tin} />
|
||||
)}
|
||||
{company.email && (
|
||||
<InfoRow icon={<Mail size={15} />} label="Email" value={company.email} />
|
||||
)}
|
||||
{company.phone && (
|
||||
<InfoRow icon={<Phone size={15} />} label="Phone" value={company.phone} />
|
||||
)}
|
||||
{company.address && (
|
||||
<InfoRow icon={<MapPin size={15} />} label="Address" value={company.address} />
|
||||
)}
|
||||
{contact && (
|
||||
<InfoRow icon={<User size={15} />} label="Contact person" value={contact} />
|
||||
)}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,99 @@
|
||||
import { Box, Group, Table, Text } from "@mantine/core";
|
||||
import { AlertTriangle, Flame, Snowflake, Undo2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import type {
|
||||
BookingContainerLineDetail,
|
||||
BookingContainerUnitDetail,
|
||||
BookingDetail,
|
||||
} from "../booking-detail-types";
|
||||
import { fmtWeight, totalVgmTons } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
/**
|
||||
* Per-container-type breakdown for container bookings (count, type, VGM).
|
||||
* Renders nothing for bulk bookings, which have no container lines.
|
||||
*/
|
||||
export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const containers = booking.containers ?? [];
|
||||
if (booking.freightType === "BULK" || containers.length === 0) return null;
|
||||
function containerTypeLabel(line: BookingContainerLineDetail): string {
|
||||
const t = line.containerType;
|
||||
if (t?.label) return t.label;
|
||||
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
|
||||
return t?.code ?? "Container";
|
||||
}
|
||||
|
||||
const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
|
||||
const totalVgm = containers.reduce(
|
||||
(sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
|
||||
0,
|
||||
function Flag({ icon, label }: { icon: ReactNode; label: string }) {
|
||||
return (
|
||||
<Group
|
||||
component="span"
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "2px 8px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text fz={13} fw={700} c="#10202F">
|
||||
{unit.containerNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{unit.sealNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{unit.isHazardous && (
|
||||
<Flag icon={<AlertTriangle size={11} />} label="Hazardous" />
|
||||
)}
|
||||
{unit.isReefer && <Flag icon={<Snowflake size={11} />} label="Reefer" />}
|
||||
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
|
||||
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} fw={600} c={unit.receivedToPort ? "#0A6F4D" : "#9AA8B5"}>
|
||||
{unit.receivedToPort ? "Received" : "Pending"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-container breakdown for container bookings — real per-line data
|
||||
* (`bookingContainers`, joined with per-unit numbers/seals/VGM) rather than
|
||||
* the legacy `booking.containers` DTO shape, which the detail endpoint
|
||||
* never populates. Renders nothing for bulk bookings.
|
||||
*/
|
||||
export function ContainersCard({ booking }: { booking: BookingDetail }) {
|
||||
const lines = booking.bookingContainers ?? [];
|
||||
if (booking.freightType === "BULK" || lines.length === 0) return null;
|
||||
|
||||
const totalUnits = lines.reduce((sum, c) => sum + Number(c.quantity || 0), 0);
|
||||
const totalVgm = totalVgmTons(booking);
|
||||
const allUnits = lines.flatMap((l) => l.units ?? []);
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
@@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Table verticalSpacing="sm" horizontalSpacing={0}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing={0} mb={allUnits.length ? "lg" : 0}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
|
||||
@@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((c, i) => {
|
||||
const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
|
||||
{lines.map((c, i) => {
|
||||
const lineVgm = Number(c.totalVgmTons || 0);
|
||||
return (
|
||||
<Table.Tr key={`${c.type}-${i}`}>
|
||||
<Table.Tr key={c.id ?? i}>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{c.type}
|
||||
</Text>
|
||||
<Group gap={8} wrap="wrap">
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{containerTypeLabel(c)}
|
||||
</Text>
|
||||
{c.isOverweight && (
|
||||
<Flag icon={<AlertTriangle size={11} />} label="Overweight" />
|
||||
)}
|
||||
{!!c.hazardousQuantity && (
|
||||
<Flag icon={<Flame size={11} />} label={`${c.hazardousQuantity} hazardous`} />
|
||||
)}
|
||||
{!!c.reeferQuantity && (
|
||||
<Flag icon={<Snowflake size={11} />} label={`${c.reeferQuantity} reefer`} />
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#10202F">
|
||||
{c.qty}
|
||||
{c.quantity}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#475569">
|
||||
{c.vgm ? `${c.vgm} t` : "—"}
|
||||
{fmtWeight(Number(c.vgmPerUnitTons || 0))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F" ta="right">
|
||||
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
|
||||
{fmtWeight(lineVgm)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{allUnits.length > 0 && (
|
||||
<Box>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={8}>
|
||||
Container numbers
|
||||
</Text>
|
||||
<Table verticalSpacing="xs" horizontalSpacing={0}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Container no.</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Seal no.</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>VGM</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Flags</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Port status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{allUnits.map((u) => (
|
||||
<UnitRow key={u.id} unit={u} />
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
@@ -78,7 +190,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
Total weight (VGM)
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#0A6F4D">
|
||||
{totalVgm.toLocaleString()} t
|
||||
{fmtWeight(totalVgm)}
|
||||
</Text>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Anchor, Box, Divider, Group, Text } from "@mantine/core";
|
||||
import { FileText, Link2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function Field({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Box miw={0} flex={1}>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={4} fz="14px" fw={700} c="#10202F">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Group
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
py={13}
|
||||
style={{ borderBottom: "1px solid #F2F5F8" }}
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract terms for this booking — validity window, financial terms,
|
||||
* customs/currency, renewal chain — none of which the detail page surfaced
|
||||
* before even though the booking always carries them.
|
||||
*/
|
||||
export function ContractInfoCard({ booking }: { booking: BookingDetail }) {
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const hasValidity = booking.contractValidFrom || booking.contractValidUntil;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb={4}>
|
||||
<CardTitle>Contract Information</CardTitle>
|
||||
{booking.contractId && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.contractId}`}
|
||||
fz="12.5px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
View full contract →
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box>
|
||||
<Row>
|
||||
<Field
|
||||
label="Contract reference"
|
||||
value={
|
||||
booking.contractId ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.contractId}`}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
{booking.contractReference ?? booking.reference}
|
||||
</Anchor>
|
||||
) : (
|
||||
(booking.contractReference ?? booking.reference)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Contract type"
|
||||
value={booking.contractType === "RENEWAL" ? "Renewal" : "New"}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Field
|
||||
label="Order type"
|
||||
value={isGeneralContract ? "General Contract" : "One-Time Booking"}
|
||||
/>
|
||||
<Field label="Payment currency" value={booking.paymentCurrency || "—"} />
|
||||
</Row>
|
||||
|
||||
{hasValidity && (
|
||||
<Row>
|
||||
<Field label="Valid from" value={fmtDate(booking.contractValidFrom)} />
|
||||
<Field
|
||||
label={
|
||||
booking.contractValidityDays
|
||||
? `Valid until (${booking.contractValidityDays} days)`
|
||||
: "Valid until"
|
||||
}
|
||||
value={fmtDate(booking.contractValidUntil)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{isGeneralContract && (booking.startDate || booking.endDate) && (
|
||||
<Row>
|
||||
<Field label="Service start" value={fmtDate(booking.startDate)} />
|
||||
<Field label="Service end" value={fmtDate(booking.endDate)} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{isGeneralContract && booking.expiresAt && (
|
||||
<Row>
|
||||
<Field label="Ordering window closes" value={fmtDate(booking.expiresAt)} />
|
||||
<Field label="Version" value={`v${booking.versionNumber ?? 1}`} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{booking.customsClearingEnabled && (
|
||||
<Row>
|
||||
<Field label="Customs clearance" value="Enabled" />
|
||||
<Field
|
||||
label="Clearing agent"
|
||||
value={booking.customsClearingAgent || "Assigned by Global Logistics"}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{booking.previousContractId && (
|
||||
<Row>
|
||||
<Field
|
||||
label="Renewed from"
|
||||
value={
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.previousContractId}`}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Link2 size={13} />
|
||||
Previous contract
|
||||
</Group>
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Consolidation"
|
||||
value={
|
||||
booking.consolidationPartner
|
||||
? `Paired with ${booking.consolidationPartner.reference}`
|
||||
: booking.consolidationPartnerId
|
||||
? "Paired"
|
||||
: "Not consolidated"
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{booking.financialTerms && (
|
||||
<>
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={6}>
|
||||
Financial terms
|
||||
</Text>
|
||||
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{booking.financialTerms}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{booking.contractSummary && (
|
||||
<>
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
<Group gap={6} align="center" mb={6}>
|
||||
<FileText size={13} color="#9AA8B5" />
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
Contract summary
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{booking.contractSummary}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
|
||||
import { fmtDate, isDraftLike, isNegative } from "../utils";
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
type Row = { label: string; value: ReactNode; muted?: boolean };
|
||||
@@ -50,14 +49,11 @@ export function ScheduleCard({
|
||||
title,
|
||||
consignment,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
booking: BookingDetail;
|
||||
title: string;
|
||||
consignment?: boolean;
|
||||
}) {
|
||||
const service =
|
||||
booking.serviceType === "RAIL_AND_FORWARDING"
|
||||
? "Rail + Forwarding"
|
||||
: "Rail only";
|
||||
const service = serviceTypeLabel(booking);
|
||||
const equipmentReturn =
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||
const assignedTrain: Row = {
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { containerSummary, fmtDate, yardLabel } from "../utils";
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import {
|
||||
commodityLabel,
|
||||
containerSummary,
|
||||
fmtDate,
|
||||
fmtWeight,
|
||||
serviceTypeLabel,
|
||||
shippingLineLabel,
|
||||
totalVgmTons,
|
||||
yardLabel,
|
||||
} from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) {
|
||||
function Badge({ label, tone }: { label: string; tone: "amber" | "blue" }) {
|
||||
const palette =
|
||||
tone === "amber"
|
||||
? { bg: "#FFFBEB", border: "#FDE68A", color: "#92400E" }
|
||||
: { bg: "#EAF1FE", border: "#CFDDFB", color: "#1E40AF" };
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
padding: "3px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: palette.color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
|
||||
const weight = totalVgmTons(booking);
|
||||
const rows: [string, string][][] = [
|
||||
[
|
||||
["Origin yard", yardLabel(booking.originYard)],
|
||||
@@ -14,22 +47,14 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
||||
],
|
||||
[
|
||||
["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"],
|
||||
["Commodity", booking.freightSubtype || "—"],
|
||||
["Commodity", commodityLabel(booking)],
|
||||
],
|
||||
[
|
||||
["Containers / load", containerSummary(booking)],
|
||||
[
|
||||
"Total weight (VGM)",
|
||||
booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—",
|
||||
],
|
||||
["Total weight (VGM)", fmtWeight(weight)],
|
||||
],
|
||||
[
|
||||
[
|
||||
"Service type",
|
||||
booking.serviceType === "RAIL_AND_FORWARDING"
|
||||
? "Rail + Forwarding"
|
||||
: "Rail only",
|
||||
],
|
||||
["Service type", serviceTypeLabel(booking)],
|
||||
[
|
||||
"Equipment return",
|
||||
booking.equipmentReturn === "WITH_RETURN"
|
||||
@@ -44,32 +69,49 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
||||
],
|
||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||
],
|
||||
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
|
||||
[
|
||||
["Shipping line", shippingLineLabel(booking)],
|
||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
||||
],
|
||||
];
|
||||
|
||||
const badges: string[] = [];
|
||||
if (booking.isHazardous) badges.push("Hazardous");
|
||||
if (booking.isRefrigerated) badges.push("Refrigerated");
|
||||
if (booking.customsClearingEnabled) badges.push("Customs clearance");
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" pb={3}>
|
||||
<CardTitle>Shipment Details</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
<FileText size={13} />
|
||||
{booking.contractType === "RENEWAL"
|
||||
? "Renewal contract"
|
||||
: "New contract"}
|
||||
<Group gap={6} wrap="wrap" justify="flex-end">
|
||||
{badges.map((b) => (
|
||||
<Badge
|
||||
key={b}
|
||||
label={b}
|
||||
tone={b === "Customs clearance" ? "blue" : "amber"}
|
||||
/>
|
||||
))}
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
<FileText size={13} />
|
||||
{booking.contractType === "RENEWAL"
|
||||
? "Renewal contract"
|
||||
: "New contract"}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { format } from "date-fns";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { rawServiceType, type BookingDetail } from "./booking-detail-types";
|
||||
|
||||
export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
|
||||
export const isDraftLike = (s: string) =>
|
||||
s === "DRAFT" || s === "CHANGES_REQUESTED";
|
||||
@@ -37,17 +39,105 @@ export function yardLabel(y?: Freight.IBooking["originYard"]) {
|
||||
return y?.label ?? y?.code ?? "—";
|
||||
}
|
||||
|
||||
export function containerSummary(b: Freight.IBooking) {
|
||||
function containerLineLabel(c: NonNullable<BookingDetail["bookingContainers"]>[number]) {
|
||||
const t = c.containerType;
|
||||
if (t?.label) return t.label;
|
||||
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""}`;
|
||||
return t?.code ?? "Container";
|
||||
}
|
||||
|
||||
/**
|
||||
* The real per-line container data lives on `bookingContainers` (joined
|
||||
* relation, with per-unit numbers + VGM) — `booking.containers` is a
|
||||
* frontend-only DTO shape the create/update flows use that the detail
|
||||
* endpoint never populates, so it's kept only as a last-resort fallback.
|
||||
*/
|
||||
export function containerSummary(b: BookingDetail) {
|
||||
const lines = b.bookingContainers ?? [];
|
||||
if (lines.length > 0) {
|
||||
return lines.map((c) => `${c.quantity} × ${containerLineLabel(c)}`).join(", ");
|
||||
}
|
||||
if (b.containers?.length) {
|
||||
return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", ");
|
||||
}
|
||||
return b.freightType === "BULK" ? "Bulk cargo" : "—";
|
||||
}
|
||||
|
||||
export function bookingSubtitle(b: Freight.IBooking) {
|
||||
const cargo =
|
||||
/**
|
||||
* Total shipped weight (VGM), in tons. Container bookings compute the real
|
||||
* total from `bookingContainers[].totalVgmTons` (per-line quantity × VGM)
|
||||
* because `cargoTotalWeightVgm` is often left at 0 for container freight —
|
||||
* the VGM is captured per container, not as a single booking-level figure.
|
||||
* Falls back to `cargoTotalWeightVgm` for bulk freight / legacy rows.
|
||||
*/
|
||||
export function totalVgmTons(b: BookingDetail): number {
|
||||
const lines = b.bookingContainers ?? [];
|
||||
if (lines.length > 0) {
|
||||
const sum = lines.reduce((s, c) => s + Number(c.totalVgmTons || 0), 0);
|
||||
if (sum > 0) return sum;
|
||||
}
|
||||
if (b.containers?.length) {
|
||||
const sum = b.containers.reduce(
|
||||
(s, c) => s + Number(c.vgm || 0) * Number(c.qty || 0),
|
||||
0,
|
||||
);
|
||||
if (sum > 0) return sum;
|
||||
}
|
||||
return Number(b.cargoTotalWeightVgm || 0);
|
||||
}
|
||||
|
||||
export function fmtWeight(tons: number): string {
|
||||
return tons > 0 ? `${tons.toLocaleString(undefined, { maximumFractionDigits: 3 })} t` : "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* `booking.serviceType` is declared as the legacy "RAIL_ONLY" |
|
||||
* "RAIL_AND_FORWARDING" string on the shared type, but the API sends the
|
||||
* joined ServiceType relation object. Handle both shapes, with a fallback
|
||||
* derived from the first/last-mile addresses when neither is present.
|
||||
*/
|
||||
export function serviceTypeLabel(b: BookingDetail): string {
|
||||
const st = rawServiceType(b);
|
||||
if (st && typeof st === "object") {
|
||||
if (st.serviceName) return st.serviceName;
|
||||
if (st.code) {
|
||||
return st.code
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
}
|
||||
if (typeof st === "string") {
|
||||
return st === "RAIL_AND_FORWARDING" ? "Rail + Forwarding" : "Rail only";
|
||||
}
|
||||
return b.firstMilePickupAddress || b.lastMileDeliveryAddress
|
||||
? "Rail + Forwarding"
|
||||
: "Rail only";
|
||||
}
|
||||
|
||||
/** Real commodity name from the joined cargo type, falling back to the
|
||||
* free-text commodity entered at booking time. `freightSubtype` is a
|
||||
* legacy field the API no longer sends. */
|
||||
export function commodityLabel(b: BookingDetail): string {
|
||||
return (
|
||||
b.cargoType?.cargoTypeName ||
|
||||
b.cargoFreeText ||
|
||||
b.freightSubtype ||
|
||||
(b.freightType === "BULK" ? "Bulk freight" : "Container freight");
|
||||
"—"
|
||||
);
|
||||
}
|
||||
|
||||
export function shippingLineLabel(b: BookingDetail): string {
|
||||
return b.shippingLine?.label || b.shippingLine?.code || "—";
|
||||
}
|
||||
|
||||
export function bookingSubtitle(b: BookingDetail) {
|
||||
const cargo =
|
||||
commodityLabel(b) !== "—"
|
||||
? commodityLabel(b)
|
||||
: b.freightType === "BULK"
|
||||
? "Bulk freight"
|
||||
: "Container freight";
|
||||
const load = containerSummary(b);
|
||||
const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`;
|
||||
return [cargo, load, route].filter((p) => p && p !== "—").join(" · ");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* content-sized columns with a 40px floor, horizontal scroll when the table
|
||||
* outgrows the card, and a sticky shadowed action column.
|
||||
*/
|
||||
.edr-bookings-table {
|
||||
.edr-bookings-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,11 @@ export function Step2ServiceType({
|
||||
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
|
||||
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
|
||||
}
|
||||
// The customs clearing agent field is hidden for intercity — drop any value
|
||||
// carried over from a draft or an operation-type switch.
|
||||
if (isIntercity && form.getValues("customsClearingAgent")) {
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
}
|
||||
}, [isIntercity, form]);
|
||||
|
||||
return (
|
||||
@@ -538,7 +543,9 @@ export function Step2ServiceType({
|
||||
)}
|
||||
|
||||
|
||||
{includesCustoms ? (
|
||||
{/* Intercity (domestic) moves never cross a border, so no customs
|
||||
clearing agent is collected. */}
|
||||
{isIntercity ? null : includesCustoms ? (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
-- Widen PaymentIntent.amountMinor from integer to double precision so fractional
|
||||
-- charge amounts (e.g. 1700.49 after ETB->DJF FX conversion) mirror the
|
||||
-- edr_payment.payment_intent.amount_minor source of truth instead of truncating.
|
||||
ALTER TABLE "passenger"."PaymentIntent" ALTER COLUMN "amountMinor" SET DATA TYPE DOUBLE PRECISION;
|
||||
@@ -621,7 +621,7 @@ model PaymentMethod {
|
||||
model PaymentIntent {
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique
|
||||
amountMinor Int
|
||||
amountMinor Float
|
||||
currency String @default("ETB")
|
||||
method PaymentMethodType
|
||||
provider String?
|
||||
|
||||
@@ -162,29 +162,39 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct totalMinor for a booking, accounting for package round-trip bookings
|
||||
* where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
* A package round-trip booking has packageId set, bookingType ROUND_TRIP, and
|
||||
* totalMinor equal to a single-leg fare (i.e. seats split evenly across 2 legs).
|
||||
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
|
||||
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
*/
|
||||
private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise<number> {
|
||||
private async resolveBookingTotal(booking: {
|
||||
id: string;
|
||||
totalMinor: number;
|
||||
bookingType: string;
|
||||
packageId?: string | null;
|
||||
priceTierId?: string | null;
|
||||
displayTotalMinor?: number | null;
|
||||
}): Promise<number> {
|
||||
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
|
||||
return booking.totalMinor;
|
||||
}
|
||||
// For package round-trip bookings, recompute from the tier price to handle
|
||||
// bookings created before the server fix stored the full round-trip total.
|
||||
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
|
||||
// totalMinor was already computed in ETB at creation time — no recomputation needed.
|
||||
if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
|
||||
return booking.totalMinor;
|
||||
}
|
||||
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
|
||||
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
|
||||
if (!tier) return booking.totalMinor;
|
||||
// Count adults and children from booking seats
|
||||
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
|
||||
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
|
||||
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
|
||||
const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs
|
||||
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
|
||||
// always in the same units as totalMinor (which is always the ETB canonical).
|
||||
const rawFare = tier.priceMinor * 2;
|
||||
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
|
||||
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
|
||||
: rawFare;
|
||||
const childFareMinor = Math.round(adultFareMinor * 0.1);
|
||||
const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor;
|
||||
// If stored total already matches the correct round-trip total, use it as-is.
|
||||
// If it's roughly half (single-leg), use the recomputed value.
|
||||
return correctTotal;
|
||||
return adultCount * adultFareMinor + childCount * childFareMinor;
|
||||
}
|
||||
|
||||
async initiatePayment(
|
||||
|
||||
@@ -36,6 +36,34 @@ export class ReportsController {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payment-discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||
getPaymentDiscrepancy(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getPaymentsReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments/discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||
getPaymentDiscrepancyBySchedule(
|
||||
@Query('scheduleId') scheduleId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('seatClass') seatClass?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
|
||||
@@ -412,20 +412,27 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
const now = new Date();
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: now } },
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
isPackageOnly: true,
|
||||
train: { select: { number: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: "desc" },
|
||||
orderBy: { departureAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
|
||||
departureAt: s.departureAt,
|
||||
isPackage: s.isPackageOnly,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${
|
||||
s.isPackageOnly ? ' (package)' : ''
|
||||
}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -591,6 +598,413 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyReport(params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies).
|
||||
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: 'ETB' as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) {
|
||||
rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert any minor amount to its ETB equivalent using stored exchange rates.
|
||||
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB),
|
||||
// so callers should pass that directly rather than converting displayTotalMinor.
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === 'ETB') return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
// If no rate is on file fall back to the raw value (avoids silently hiding
|
||||
// cross-currency bookings, at the cost of an approximate comparison).
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
if (params.search?.trim()) {
|
||||
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
|
||||
}
|
||||
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z');
|
||||
if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z');
|
||||
|
||||
const seatSelect = {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' as const } } },
|
||||
{ seat: { seatNumber: 'asc' as const } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
displayFareMinor: true,
|
||||
displayCurrency: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }),
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: seatSelect,
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = bookings
|
||||
.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
|
||||
// Display amounts shown to the passenger (may be in DJF).
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
|
||||
const paidMinor = pi.amountMinor;
|
||||
const paidCurrency = pi.currency;
|
||||
|
||||
// b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount
|
||||
// (the gateway receives major units — displayMinorToChargeMajor divides by 100 before
|
||||
// sending). Multiply by 100 to convert back to minor before the ETB comparison.
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
return {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
passengerCount: passengers.length,
|
||||
passengers,
|
||||
};
|
||||
})
|
||||
.filter(r => r.balanceMinor > 0);
|
||||
|
||||
if (params.sortBy === 'departure') {
|
||||
rows.sort((a, b) => new Date(a.departureAt).getTime() - new Date(b.departureAt).getTime());
|
||||
} else {
|
||||
rows.sort((a, b) => b.balanceMinor - a.balanceMinor);
|
||||
}
|
||||
|
||||
const totalBalanceEtbMinor = rows.reduce((sum, r) => sum + r.balanceMinor, 0);
|
||||
|
||||
return { total: rows.length, totalBalanceEtbMinor, rows };
|
||||
}
|
||||
|
||||
private async getDiscrepancyForRef(
|
||||
search: string,
|
||||
toEtbMinor: (minor: number, currency: string) => number,
|
||||
) {
|
||||
let bookingId: string | null = null;
|
||||
const byPnr = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: search.toUpperCase() },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byPnr) {
|
||||
bookingId = byPnr.id;
|
||||
} else {
|
||||
const ticket = await this.prisma.ticket.findFirst({
|
||||
where: { barcodePayload: search },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
bookingId = ticket?.bookingId ?? null;
|
||||
}
|
||||
|
||||
if (!bookingId) {
|
||||
return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
}
|
||||
|
||||
const b = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true, status: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true, code: true, city: true } },
|
||||
destinationStation: { select: { name: true, code: true, city: true } },
|
||||
},
|
||||
},
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' } } },
|
||||
{ seat: { seatNumber: 'asc' } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
displayFareMinor: true,
|
||||
displayCurrency: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: {
|
||||
select: { user: { select: { phone: true, fullName: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!b) return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||
|
||||
const pi = b.paymentIntent;
|
||||
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const paidMinor = pi?.amountMinor ?? 0;
|
||||
const paidCurrency = pi?.currency ?? b.currency;
|
||||
|
||||
const owedEtb = b.totalMinor;
|
||||
const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency);
|
||||
const balanceMinor = owedEtb - paidEtb;
|
||||
const balanceCurrency = 'ETB';
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
|
||||
const row = {
|
||||
pnr: b.bookingRef,
|
||||
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
bookingDate: b.createdAt,
|
||||
origin: b.schedule.originStation,
|
||||
destination: b.schedule.destinationStation,
|
||||
departureAt: b.schedule.departureAt,
|
||||
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
actualMinor,
|
||||
actualCurrency,
|
||||
paidMinor,
|
||||
paidCurrency,
|
||||
balanceMinor,
|
||||
balanceCurrency,
|
||||
bookingStatus: b.status,
|
||||
paymentStatus: pi?.status ?? null,
|
||||
passengerCount: passengers.length,
|
||||
passengers,
|
||||
};
|
||||
|
||||
return {
|
||||
total: balanceMinor > 0 ? 1 : 0,
|
||||
totalBalanceEtbMinor: balanceMinor > 0 ? balanceMinor : 0,
|
||||
rows: [row],
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSeatPassengers(seats: any[], fallbackCurrency: string) {
|
||||
return seats.map(s => ({
|
||||
name: s.passengerName as string,
|
||||
category: s.passengerCategory as string,
|
||||
seatNumber: (s.seat?.seatNumber ?? null) as string | null,
|
||||
coachNumber: (s.seat?.coach?.number ?? null) as string | null,
|
||||
seatType: (s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? null) as string | null,
|
||||
fareMinor: (s.displayFareMinor ?? s.fareMinor ?? null) as number | null,
|
||||
fareCurrency: ((s.displayCurrency as string | null) ?? fallbackCurrency),
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsReport(scheduleId: string) {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } },
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
select: {
|
||||
passengerName: true,
|
||||
fareMinor: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||
},
|
||||
},
|
||||
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const rows = bookings.map(b => {
|
||||
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||
const paidMinor = Math.round(b.paymentIntent!.amountMinor);
|
||||
return {
|
||||
bookingRef: b.bookingRef,
|
||||
passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
method: b.paymentIntent!.method,
|
||||
paidAt: b.paymentIntent!.paidAt,
|
||||
actualMinor,
|
||||
paidMinor,
|
||||
currency: 'ETB',
|
||||
passengerCount: b.seats.length,
|
||||
};
|
||||
});
|
||||
|
||||
const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0);
|
||||
const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0);
|
||||
|
||||
const byMethod = rows.reduce((acc, r) => {
|
||||
acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return { totalActualMinor, totalPaidMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
search?: string;
|
||||
seatClass?: string;
|
||||
sort?: string;
|
||||
}) {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
include: {
|
||||
paymentIntent: { select: { amountMinor: true, currency: true } },
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
package: { select: { id: true } },
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
orderBy: [
|
||||
{ seat: { coach: { number: 'asc' as const } } },
|
||||
{ seat: { seatNumber: 'asc' as const } },
|
||||
],
|
||||
select: {
|
||||
passengerName: true,
|
||||
passengerCategory: true,
|
||||
seatLabelSnapshot: true,
|
||||
fareMinor: true,
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const resolveSeatClass = (seat: any): string => {
|
||||
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
||||
const matched = seat?.bedPosition
|
||||
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase())
|
||||
: null;
|
||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
};
|
||||
|
||||
let rows = bookings.map(b => {
|
||||
const pi = b.paymentIntent!;
|
||||
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||
// pi.amountMinor is a Float in full currency units — convert to cents once
|
||||
const paidMinorCents = Math.round(pi.amountMinor * 100);
|
||||
|
||||
const isPackage = !!(b as any).package;
|
||||
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
|
||||
const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents;
|
||||
|
||||
const breakdown = b.seats.map(s => ({
|
||||
passengerName: s.passengerName ?? '—',
|
||||
seatClass: resolveSeatClass(s.seat),
|
||||
coachNumber: s.seat?.coach?.number ?? null,
|
||||
seatNumber: s.seat?.seatNumber ?? null,
|
||||
fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0),
|
||||
}));
|
||||
|
||||
const firstSeat = b.seats[0];
|
||||
return {
|
||||
bookingRef: b.bookingRef,
|
||||
isPackage,
|
||||
seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||
seatNumber: firstSeat?.seat?.seatNumber ?? null,
|
||||
origin: b.schedule.originStation.name,
|
||||
destination: b.schedule.destinationStation.name,
|
||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||
actualMinor: effectiveActualMinor,
|
||||
paidMinor: paidMinorCents,
|
||||
varianceMinor: effectiveVarianceMinor,
|
||||
breakdown,
|
||||
};
|
||||
}).filter(r => r.varianceMinor > 0);
|
||||
|
||||
if (params.search?.trim()) {
|
||||
const q = params.search.trim().toUpperCase();
|
||||
rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q));
|
||||
}
|
||||
if (params.seatClass?.trim()) {
|
||||
const sc = params.seatClass.trim().toLowerCase();
|
||||
rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc)));
|
||||
}
|
||||
if (params.sort === 'asc') {
|
||||
rows.sort((a, b) => a.varianceMinor - b.varianceMinor);
|
||||
} else {
|
||||
rows.sort((a, b) => b.varianceMinor - a.varianceMinor);
|
||||
}
|
||||
|
||||
return { total: rows.length, rows };
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
@@ -7,8 +7,6 @@ export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -39,8 +37,6 @@ export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
|
||||
@@ -37,8 +37,6 @@ export class RoutesService {
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null,
|
||||
plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -108,8 +106,6 @@ export class RoutesService {
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
plannedArrivalTime: s.plannedArrivalTime ?? null,
|
||||
plannedDepartureTime: s.plannedDepartureTime ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -229,8 +225,6 @@ export class RoutesService {
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
plannedArrivalTime: dto.plannedArrivalTime ?? null,
|
||||
plannedDepartureTime: dto.plannedDepartureTime ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,20 +54,12 @@ export class CreateScheduleDto {
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class CoachAssignmentDto {
|
||||
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[];
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
|
||||
@@ -133,62 +133,26 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const hasRouteTimes = route.stops.some(
|
||||
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
||||
);
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
if (hasRouteTimes) {
|
||||
// Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
|
||||
const EAT_MS = 3 * 60 * 60 * 1000;
|
||||
const depEATMs = dep.getTime() + EAT_MS;
|
||||
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
||||
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
||||
|
||||
const templateToScheduleUTC = (templateDt: Date): Date => {
|
||||
// Pull the time-of-day in EAT from the template DateTime
|
||||
const templateEATMs = templateDt.getTime() + EAT_MS;
|
||||
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
||||
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
||||
// Overnight: if the stop time lands before departure, move to next day
|
||||
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
||||
return candidate;
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
|
||||
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index > 0 && arrDt != null
|
||||
? templateToScheduleUTC(arrDt).toISOString()
|
||||
: undefined,
|
||||
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
|
||||
? templateToScheduleUTC(depDt).toISOString()
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||
@@ -340,59 +304,26 @@ export class SchedulesService {
|
||||
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const hasRouteTimes = route.stops.some(
|
||||
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
||||
);
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
if (hasRouteTimes) {
|
||||
const EAT_MS = 3 * 60 * 60 * 1000;
|
||||
const depEATMs = dep.getTime() + EAT_MS;
|
||||
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
||||
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
||||
|
||||
const templateToScheduleUTC = (templateDt: Date): Date => {
|
||||
const templateEATMs = templateDt.getTime() + EAT_MS;
|
||||
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
||||
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
||||
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
||||
return candidate;
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
|
||||
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index > 0 && arrDt != null
|
||||
? templateToScheduleUTC(arrDt).toISOString()
|
||||
: undefined,
|
||||
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
|
||||
? templateToScheduleUTC(depDt).toISOString()
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
stopTime = arr;
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
@@ -747,12 +678,6 @@ export class SchedulesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) {
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,11 @@ interface PassengerRow {
|
||||
seatClassName: string | null;
|
||||
coachNumber: string | null;
|
||||
coachType: string | null;
|
||||
coachSeat: string | null;
|
||||
nationality: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
departureAt: string | null;
|
||||
amountPaidMinor: number;
|
||||
currency: string;
|
||||
isGroupBooking: boolean;
|
||||
@@ -73,6 +75,8 @@ export default function PassengersReportPage() {
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [filterCoach, setFilterCoach] = useState("");
|
||||
const [filterOrigin, setFilterOrigin] = useState("");
|
||||
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||
const [filterCoachNumber, setFilterCoachNumber] = useState("");
|
||||
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||
ScheduleOption[]
|
||||
@@ -101,13 +105,18 @@ export default function PassengersReportPage() {
|
||||
const coachOptions = [
|
||||
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const seatClassOptions = [
|
||||
...new Set(passengerList.map((p) => p.seatClassName).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const originOptions = [
|
||||
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const coachNumberOptions = coachOptions;
|
||||
|
||||
const filteredList = passengerList
|
||||
.filter((p) => {
|
||||
if (filterCoach && p.coachNumber !== filterCoach) return false;
|
||||
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
|
||||
if (filterOrigin && p.origin !== filterOrigin) return false;
|
||||
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
||||
if (listSearch.trim()) {
|
||||
@@ -203,7 +212,9 @@ export default function PassengersReportPage() {
|
||||
setTab("occupancy");
|
||||
setListSearch("");
|
||||
setFilterCoach("");
|
||||
setFilterCoachNumber("");
|
||||
setFilterOrigin("");
|
||||
setFilterSeatClass("");
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
@@ -471,6 +482,18 @@ export default function PassengersReportPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input w-36"
|
||||
value={filterCoachNumber}
|
||||
onChange={(e) => setFilterCoachNumber(e.target.value)}
|
||||
>
|
||||
<option value="">All coaches</option>
|
||||
{coachNumberOptions.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input w-36"
|
||||
value={filterOrigin}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function PaymentDiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle, CheckCircle2, Download, Search,
|
||||
Loader2, PhoneCall, RefreshCw, X, Users, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import { parse, isValid } from 'date-fns';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Station {
|
||||
name: string;
|
||||
code: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
interface PassengerDetail {
|
||||
name: string;
|
||||
category: string;
|
||||
seatNumber: string | null;
|
||||
coachNumber: string | null;
|
||||
seatType: string | null;
|
||||
fareMinor: number | null;
|
||||
fareCurrency: string;
|
||||
}
|
||||
|
||||
interface DiscrepancyRow {
|
||||
pnr: string;
|
||||
passengerName: string;
|
||||
phone: string;
|
||||
bookingDate: string;
|
||||
origin: Station;
|
||||
destination: Station;
|
||||
departureAt: string;
|
||||
seatType: string;
|
||||
coachNumber: string | null;
|
||||
actualMinor: number;
|
||||
actualCurrency: string;
|
||||
paidMinor: number;
|
||||
paidCurrency: string;
|
||||
balanceMinor: number;
|
||||
balanceCurrency: string;
|
||||
passengerCount: number;
|
||||
passengers: PassengerDetail[];
|
||||
bookingStatus?: string;
|
||||
paymentStatus?: string | null;
|
||||
}
|
||||
|
||||
interface DiscrepancyReport {
|
||||
total: number;
|
||||
totalBalanceEtbMinor: number;
|
||||
rows: DiscrepancyRow[];
|
||||
notFound?: boolean;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtStation(name: string) {
|
||||
return name.replace(/\s+Station$/i, '');
|
||||
}
|
||||
|
||||
function fmtMoney(amount: number, currency: string) {
|
||||
return `${currency} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function exportCsv(rows: DiscrepancyRow[]) {
|
||||
const headers = [
|
||||
'PNR', 'Passenger Name', 'Phone', 'Booking Date', 'Route',
|
||||
'Departure Time', 'Seat Type', 'Coach', 'Actual Price', 'Paid Amount', 'Balance',
|
||||
];
|
||||
const lines = rows.map(r => [
|
||||
r.pnr,
|
||||
r.passengerName,
|
||||
r.phone,
|
||||
new Date(r.bookingDate).toLocaleDateString('en-GB'),
|
||||
`${fmtStation(r.origin.name)} → ${fmtStation(r.destination.name)}`,
|
||||
new Date(r.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
r.seatType,
|
||||
r.coachNumber ?? '—',
|
||||
`${r.actualCurrency} ${(r.actualMinor / 100).toFixed(2)}`,
|
||||
`${r.paidCurrency} ${r.paidMinor.toFixed(2)}`,
|
||||
`${r.balanceCurrency} ${r.balanceMinor.toFixed(2)}`,
|
||||
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
||||
|
||||
const csv = [headers.join(','), ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `payment-discrepancy-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Balance badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
function BalanceBadge({ row }: { row: DiscrepancyRow }) {
|
||||
if (row.balanceMinor <= 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-2 py-0.5 rounded-md text-xs">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
Fully paid
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-2 py-0.5 rounded-md text-xs">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
{fmtMoney(row.balanceMinor, row.balanceCurrency)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Applied = { from: string; to: string; sortBy: string; search: string };
|
||||
|
||||
export default function PaymentDiscrepancyPage() {
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
|
||||
const [search, setSearch] = useState('');
|
||||
const [applied, setApplied] = useState<Applied | null>(null);
|
||||
const [expandedPnr, setExpandedPnr] = useState<string | null>(null);
|
||||
|
||||
const isSearchMode = !!(applied?.search);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<DiscrepancyReport>({
|
||||
queryKey: ['payment-discrepancy', applied],
|
||||
queryFn: () =>
|
||||
apiClient.get('/reports/payment-discrepancy', {
|
||||
params: {
|
||||
from: applied?.search ? undefined : (applied?.from || undefined),
|
||||
to: applied?.search ? undefined : (applied?.to || undefined),
|
||||
sortBy: applied?.search ? undefined : applied?.sortBy,
|
||||
search: applied?.search || undefined,
|
||||
},
|
||||
}),
|
||||
enabled: applied !== null,
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
setApplied({ from, to, sortBy, search: search.trim() });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
setSearch('');
|
||||
setApplied(prev => prev ? { ...prev, search: '' } : null);
|
||||
}
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
|
||||
const parsedFrom = from ? parse(from, 'yyyy-MM-dd', new Date()) : undefined;
|
||||
const fromDate = parsedFrom && isValid(parsedFrom) ? parsedFrom : undefined;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-red-100 dark:bg-red-950/40">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Payment Discrepancy Report</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Bookings where the amount paid is less than the actual fare — flagged for follow-up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters — single row */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-5 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
|
||||
{/* PNR / Ticket search — grows to fill available space */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value.toUpperCase())}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="PNR or ticket number…"
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 select-none shrink-0">or</span>
|
||||
|
||||
{/* Date range */}
|
||||
<DatePicker
|
||||
value={from}
|
||||
onChange={setFrom}
|
||||
placeholder="From"
|
||||
disabled={!!search}
|
||||
/>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 select-none shrink-0">–</span>
|
||||
<DatePicker
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
placeholder="To"
|
||||
disabled={!!search}
|
||||
minDate={fromDate}
|
||||
/>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as any)}
|
||||
disabled={!!search}
|
||||
className="shrink-0 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="balance">Highest balance first</option>
|
||||
<option value="departure">Earliest departure first</option>
|
||||
</select>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{applied && (
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{rows.length > 0 && !isSearchMode && (
|
||||
<button
|
||||
onClick={() => exportCsv(rows)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
Failed to load discrepancy data. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found */}
|
||||
{data?.notFound && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
No booking found for <span className="font-mono font-bold mx-1">{applied?.search}</span> — check the PNR or ticket number and try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{rows.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{isSearchMode && (
|
||||
<div className="px-5 py-3 border-b border-gray-100 dark:border-gray-800 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Lookup result for
|
||||
</span>
|
||||
<span className="font-mono text-sm font-bold text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||
{applied?.search}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear lookup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60">
|
||||
{[
|
||||
'Passenger', 'PNR', 'Booking Date', 'Route', 'Departure',
|
||||
'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Passengers', 'Phone',
|
||||
].map(h => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{rows.filter(row => row.balanceMinor > 0).map((row, i) => {
|
||||
const isExpanded = expandedPnr === row.pnr;
|
||||
const hasMultiple = row.passengerCount > 1;
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={row.pnr + i}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{row.passengerName}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded">
|
||||
{row.pnr}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.bookingDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{fmtStation(row.origin.name)}
|
||||
</span>
|
||||
<span className="text-gray-400 dark:text-gray-500 mx-1">→</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{fmtStation(row.destination.name)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.departureAt).toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||
timeZone: 'Africa/Addis_Ababa',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-gray-900 dark:text-white">{row.seatType}</div>
|
||||
{row.coachNumber && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">Coach {row.coachNumber}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap font-medium">
|
||||
{fmtMoney(row.actualMinor / 100, row.actualCurrency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{fmtMoney(row.paidMinor, row.paidCurrency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-red-600 dark:text-red-400 font-semibold">
|
||||
<BalanceBadge row={row} />
|
||||
</td>
|
||||
{/* Passengers column */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{hasMultiple ? (
|
||||
<button
|
||||
onClick={() => setExpandedPnr(isExpanded ? null : row.pnr)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-950/50 transition-colors"
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{row.passengerCount}
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />1
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{row.phone && row.phone !== '—' ? (
|
||||
<a
|
||||
href={`tel:${row.phone}`}
|
||||
className="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<PhoneCall className="w-3.5 h-3.5 shrink-0" />
|
||||
{row.phone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expandable passenger breakdown */}
|
||||
{isExpanded && (
|
||||
<tr key={row.pnr + '-passengers'} className="bg-blue-50/50 dark:bg-blue-950/10">
|
||||
<td colSpan={11} className="px-6 py-3">
|
||||
<div className="text-xs font-semibold text-blue-700 dark:text-blue-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Passengers under {row.pnr}
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400">
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">#</th>
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Name</th>
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Category</th>
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Coach</th>
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat</th>
|
||||
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Class</th>
|
||||
<th className="pb-1.5 font-semibold uppercase tracking-wide">Fare</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-blue-100 dark:divide-blue-900/30">
|
||||
{row.passengers.map((p, pi) => (
|
||||
<tr key={pi} className="text-gray-700 dark:text-gray-300">
|
||||
<td className="py-1.5 pr-4 text-gray-400">{pi + 1}</td>
|
||||
<td className="py-1.5 pr-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">{p.name}</td>
|
||||
<td className="py-1.5 pr-4 capitalize">{p.category.toLowerCase()}</td>
|
||||
<td className="py-1.5 pr-4">{p.coachNumber ?? '—'}</td>
|
||||
<td className="py-1.5 pr-4 font-mono">{p.seatNumber ?? '—'}</td>
|
||||
<td className="py-1.5 pr-4">{p.seatType ?? '—'}</td>
|
||||
<td className="py-1.5 font-medium">
|
||||
{p.fareMinor != null
|
||||
? fmtMoney(p.fareMinor, p.fareCurrency)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!isSearchMode && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400 dark:text-gray-500">
|
||||
{rows.filter(r => r.balanceMinor > 0).length} record{rows.filter(r => r.balanceMinor > 0).length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state — date range returned nothing */}
|
||||
{applied && !isSearchMode && !isLoading && !isError && rows.length === 0 && data && !data.notFound && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">No underpaid bookings found for this period</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial state */}
|
||||
{!applied && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Search className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">Enter a PNR or ticket number above, or select a date range to generate the report</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function PaymentsReportLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user