mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
Merge pull request #1453 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
|
||||
import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
TransitAgentsModule,
|
||||
TransitAssignmentsModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
|
||||
@@ -4,25 +4,29 @@ import {
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
} from "class-validator";
|
||||
import {
|
||||
isValidPhoneNumber,
|
||||
parsePhoneNumberFromString,
|
||||
} from "libphonenumber-js";
|
||||
|
||||
/**
|
||||
* Country-aware phone validation. The value is expected as a full international
|
||||
* number (E.164, e.g. "+251911223344"), so the country is derived from the
|
||||
* value itself — no separate country field needed.
|
||||
* number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
|
||||
* Ethiopia), so the country is derived from the value itself — no separate
|
||||
* country field needed.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
|
||||
@ValidatorConstraint({ name: "IsValidPhone", async: false })
|
||||
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
return isValidPhoneNumber(value);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
|
||||
return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
|
||||
export function normalizeE164(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
const parsed = parsePhoneNumberFromString(value, 'ET');
|
||||
if (value === undefined || value === null || value === "") return value;
|
||||
const parsed = parsePhoneNumberFromString(value, "ET");
|
||||
return parsed?.isValid() ? parsed.number : value.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Give a transit agent a portal login.
|
||||
*
|
||||
* Every column is NULLABLE and nothing is backfilled: production already holds
|
||||
* transit agents that exist only as a GL-assignable roster entry, and they must
|
||||
* keep working untouched. An agent gains an account when staff invite it — at
|
||||
* which point `user_id` is filled in — so "has a login" is exactly
|
||||
* `user_id IS NOT NULL`, and the assignment flow never has to care.
|
||||
*
|
||||
* The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres
|
||||
* treats NULLs as distinct in a plain unique index only per-row; being explicit
|
||||
* documents that many account-less agents are expected to coexist.
|
||||
*/
|
||||
export class TransitAgentAccount3790000000000 implements MigrationInterface {
|
||||
name = "TransitAgentAccount3790000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS email varchar(150),
|
||||
ADD COLUMN IF NOT EXISTS phone_number varchar(30)`,
|
||||
);
|
||||
// One IAM account can back at most one transit agent — otherwise a single
|
||||
// login would resolve to two agents in `findByUserId`.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id
|
||||
ON freight.transit_agents (user_id)
|
||||
WHERE user_id IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
// Case-insensitive, matching how the repository checks for duplicates.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email
|
||||
ON freight.transit_agents (lower(email))
|
||||
WHERE email IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_email`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS phone_number,
|
||||
DROP COLUMN IF EXISTS email,
|
||||
DROP COLUMN IF EXISTS user_id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE
|
||||
* scheduling field — unassign clears it to NULL — so a paid booking pulled off
|
||||
* a train had nothing left to price a cancellation fee or credit against
|
||||
* ("This booking has no wagon requirement to cancel from."). This column is
|
||||
* stamped once, at first allocation, and never cleared: cancellation reads it
|
||||
* (falling back to a computed count for bookings never allocated).
|
||||
*/
|
||||
export class BookingCancellationWagons3800000000000 implements MigrationInterface {
|
||||
name = 'BookingCancellationWagons3800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2)
|
||||
`);
|
||||
// Backfill the bookings that still carry a live stamp.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET cancellation_wagons = wagons_required
|
||||
WHERE cancellation_wagons IS NULL
|
||||
AND wagons_required IS NOT NULL
|
||||
AND wagons_required > 0
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS cancellation_wagons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Transit assignments — one row per (booking × transit agent), so an agent
|
||||
* handles many bookings.
|
||||
*
|
||||
* Deliberately NOT the existing transit-assignee handshake on bookings
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
|
||||
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
|
||||
* and GL Djibouti about WHO will handle customs. This is the work record —
|
||||
* status, timings and documents — and nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no duration column on purpose. The time taken after the train
|
||||
* arrives is `finished_at − bookings.arrived_at`, and both halves already
|
||||
* exist; storing the difference would be a third source of truth that goes
|
||||
* stale the moment either timestamp is corrected. It is computed on read.
|
||||
*
|
||||
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
|
||||
* and `resource_id = transit_assignments.id`. That table already carries the
|
||||
* MinIO object, the upload time (`created_at`), the uploader, the edit time
|
||||
* (`updated_at`) and the supersede history, so no file table is added here.
|
||||
*/
|
||||
export class TransitAssignments3810000000000 implements MigrationInterface {
|
||||
name = "TransitAssignments3810000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL,
|
||||
transit_agent_id uuid NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
assigned_by_user_id uuid,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
|
||||
CONSTRAINT fk_transit_assignments_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
|
||||
CONSTRAINT fk_transit_assignments_agent
|
||||
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// One live assignment per (booking, agent). Partial so a soft-deleted row
|
||||
// never blocks re-assigning the same agent to the same booking later.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
|
||||
ON freight.transit_assignments (booking_id, transit_agent_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// The two list directions: a booking's assignments, and an agent's workload.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
|
||||
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
|
||||
ON freight.transit_assignments (transit_agent_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: { bulkTons?: number };
|
||||
totalWagons: number;
|
||||
}>;
|
||||
};
|
||||
const booking = {
|
||||
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
|
||||
it('cancels every wagon with the exact total tonnage', async () => {
|
||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
|
||||
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
|
||||
expect(cut).toEqual({
|
||||
wagons: 4,
|
||||
weightTons: 250.5,
|
||||
quantities: { bulkTons: 250.5 },
|
||||
totalWagons: 4,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so
|
||||
* cancellation used to reject it outright ("no wagon requirement to cancel
|
||||
* from"). The pinned `cancellationWagons`, stamped at first allocation, keeps
|
||||
* the footprint through the unassign.
|
||||
*/
|
||||
it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => {
|
||||
const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 };
|
||||
const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 });
|
||||
expect(cut.wagons).toBe(4);
|
||||
expect(cut.totalWagons).toBe(4);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
/** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */
|
||||
it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => {
|
||||
const fresh = {
|
||||
...booking,
|
||||
wagonsRequired: null,
|
||||
cancellationWagons: null,
|
||||
bulkRequestedWagons: 3,
|
||||
};
|
||||
const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 });
|
||||
expect(cut.totalWagons).toBe(3);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
it('rejects more wagons than the booking has', async () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -74,6 +76,8 @@ interface RequestedCut {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: CancelledQuantities;
|
||||
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
|
||||
totalWagons: number;
|
||||
}
|
||||
|
||||
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
|
||||
@@ -165,7 +169,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
creditAmount: round2(Number(booking.totalAmount ?? 0)),
|
||||
};
|
||||
}
|
||||
this.assertCutSparesSharedWagon(cut);
|
||||
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, cut.wagons),
|
||||
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,7 +222,7 @@ export class BookingWagonCancellationService {
|
||||
: await this.resolveRequestedCut(booking, dto);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
|
||||
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
||||
where: { bookingId: row.bookingId },
|
||||
});
|
||||
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
|
||||
if (rows < Math.round(await this.wagonFootprint(booking))) {
|
||||
throw new ConflictException(
|
||||
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
|
||||
);
|
||||
@@ -363,7 +367,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
reason ?? 'Consolidated pair cancelled',
|
||||
userId,
|
||||
);
|
||||
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
|
||||
await this.openConsolidationBreak(
|
||||
partner,
|
||||
'floor',
|
||||
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
||||
round2(Number(partner.totalAmount ?? 0)),
|
||||
`Cancelled with its consolidation partner ${booking.reference}`,
|
||||
userId,
|
||||
);
|
||||
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
return this.resolveRequestedCut(booking, {
|
||||
wagons: Number(booking.wagonsRequired ?? 0),
|
||||
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
|
||||
wagons: await this.wagonFootprint(booking),
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
|
||||
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
||||
);
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
@@ -729,9 +734,10 @@ export class BookingWagonCancellationService {
|
||||
// Whole-booking cut: nothing is left to ship, so the booking ends
|
||||
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
||||
// train. The credit row still points at it for T3.
|
||||
const wagonsLeft = round2(
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
// Off the pinned footprint, not the live wagonsRequired — unassign
|
||||
// clears that to NULL, which read as a full cut on any partial cancel.
|
||||
const footprint = await this.wagonFootprint(booking);
|
||||
const wagonsLeft = round2(footprint - Number(row.wagonsCancelled));
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
// Keep the cancellation footprint in step, so a second partial cancel
|
||||
// prices against what is actually left, not the original booking.
|
||||
cancellationWagons: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
@@ -853,14 +862,37 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
|
||||
// modal) instead of the whole remainder. Anything already LOADED is
|
||||
// rejected rather than silently dropped: the operator believes they are
|
||||
// cancelling that wagon, and it is on the train.
|
||||
let target = remaining;
|
||||
if (dto.wagonAllocationIds?.length) {
|
||||
const wanted = new Set(dto.wagonAllocationIds);
|
||||
const known = new Set(allocations.map((a) => a.id));
|
||||
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
|
||||
if (unknown.length) {
|
||||
throw new BadRequestException(
|
||||
'Some selected wagons are not allocated to this booking on this schedule.',
|
||||
);
|
||||
}
|
||||
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
|
||||
if (loaded.length) {
|
||||
throw new BadRequestException(
|
||||
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
|
||||
);
|
||||
}
|
||||
target = remaining.filter((a) => wanted.has(a.id));
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
wagonAllocationIds: target.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
|
||||
booking: Booking,
|
||||
dto: RequestWagonCancellationDto,
|
||||
): Promise<RequestedCut> {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
const totalWagons = await this.wagonFootprint(booking);
|
||||
if (totalWagons <= 0) {
|
||||
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
||||
}
|
||||
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
|
||||
weightTons: weightShare,
|
||||
// Bookings without unit records fall back to the T2 LIFO trim.
|
||||
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
|
||||
if (tons <= 0) {
|
||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||
}
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: tons,
|
||||
quantities: { bulkTons: tons, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
||||
quantities: { bySize, units, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's wagon footprint for cancellation pricing.
|
||||
*
|
||||
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
|
||||
* a paid booking pulled off a train read 0 wagons and could not be cancelled
|
||||
* at all. `cancellationWagons` is stamped once at first allocation and never
|
||||
* cleared — read it first. A booking never allocated has neither, so size it
|
||||
* from the cargo the same way the scheduler would: TEU geometry for
|
||||
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
|
||||
* ÷ wagon capacity for PER_TON bulk.
|
||||
*/
|
||||
private async wagonFootprint(booking: Booking): Promise<number> {
|
||||
const pinned = Number(booking.cancellationWagons ?? 0);
|
||||
if (pinned > 0) return round2(pinned);
|
||||
const stored = Number(booking.wagonsRequired ?? 0);
|
||||
if (stored > 0) return round2(stored);
|
||||
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (requested > 0) return requested;
|
||||
|
||||
// Cargo relations drive the sizing — reload when the caller passed a bare
|
||||
// booking (findById does not always hydrate them).
|
||||
const full =
|
||||
booking.bookingContainers || booking.cargoType
|
||||
? booking
|
||||
: ((await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: booking.id },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
})) ?? booking);
|
||||
const capacities = (full.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
const bulkCapacity =
|
||||
full.freightType === 'BULK' && capacities.length
|
||||
? Math.max(...capacities)
|
||||
: undefined;
|
||||
return round2(wagonsRequiredForBooking(full, bulkCapacity));
|
||||
}
|
||||
|
||||
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
|
||||
private creditFor(booking: Booking, wagons: number): number {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
|
||||
if (totalWagons <= 0) return 0;
|
||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
||||
}
|
||||
|
||||
@@ -1786,6 +1786,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
Booking,
|
||||
| 'schedulingStatus'
|
||||
| 'wagonsRequired'
|
||||
| 'cancellationWagons'
|
||||
| 'scheduledAt'
|
||||
| 'holdStartedAt'
|
||||
| 'holdExpiresAt'
|
||||
|
||||
@@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto {
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' +
|
||||
'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' +
|
||||
'Already-loaded wagons are rejected — they are riding.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
// Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is
|
||||
// a LIVE scheduling field that unassign clears; this one is stamped once at
|
||||
// first allocation and never cleared, so a paid booking pulled off a train
|
||||
// can still price its cancellation fee and credit.
|
||||
@Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
cancellationWagons?: number | null;
|
||||
|
||||
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||
schedulingStatus!: string;
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
TransitAgentInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { TransitAgentsService } from "../transit-agents/transit-agents.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -104,6 +106,7 @@ export class CompaniesController {
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -133,10 +136,10 @@ export class CompaniesController {
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
// Neither a shipping line nor a transit agent has a company or an external
|
||||
// profile, so the customer lookup below would 404 for both. Checked first,
|
||||
// and reported with an explicit `accountKind` so the portal can skip
|
||||
// onboarding for them without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
@@ -144,6 +147,11 @@ export class CompaniesController {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
|
||||
if (transitAgent) {
|
||||
return new TransitAgentInfoResponseDto(transitAgent);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
// shipping-line session, which has no company row to look up. forwardRef
|
||||
// because that module imports BillingModule, which imports this one.
|
||||
forwardRef(() => ShippingLineCompaniesModule),
|
||||
// `GET /companies/getInfo` resolves a transit-agent session before falling
|
||||
// through to the customer lookup. TransitAgentsModule is a leaf here — it
|
||||
// does not import CompaniesModule — so no forwardRef is needed.
|
||||
TransitAgentsModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
@@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
* whenever the request failed. A shipping line and a transit agent are each
|
||||
* identified positively, and anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in transit agent. Like a shipping line: no company, no profile, no
|
||||
* onboarding — but a separate account kind because the two share nothing beyond
|
||||
* that, and the portal shows each a different (much smaller) set of tabs.
|
||||
*/
|
||||
export class TransitAgentInfoResponseDto {
|
||||
@ApiProperty({ enum: ["transit_agent"] })
|
||||
accountKind: "transit_agent" = "transit_agent";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
email?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Start of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validFrom: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "End of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validTo: string;
|
||||
|
||||
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: TransitAgent) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email ?? null;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.isActive = entity.isActive;
|
||||
this.validFrom = entity.validFrom;
|
||||
this.validTo = entity.validTo;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
| ShippingLineInfoResponseDto
|
||||
| TransitAgentInfoResponseDto;
|
||||
|
||||
@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||
"rejects non-domestic or malformed %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||
// Djibouti is the line's other end: the gateway reaches its 77x mobiles.
|
||||
it.each(["+25377123456", "25377123456", "77123456"])(
|
||||
"accepts Djibouti mobile form %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"+14155550123",
|
||||
"+447911123456",
|
||||
"0712345678",
|
||||
"+2519866",
|
||||
"12345",
|
||||
// Djibouti fixed line (2x) — valid number, not a mobile the gateway serves.
|
||||
"+25321350000",
|
||||
// Right length, wrong Djibouti prefix.
|
||||
"+25366123456",
|
||||
])("rejects unreachable or malformed %s", (phone) =>
|
||||
expect(isDomesticPhone(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string {
|
||||
if (digits.startsWith("+")) return digits;
|
||||
const bare = digits.replace(/^0+/, "");
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^253\d{8}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a
|
||||
// bare "77…" is unambiguous — it cannot be an Ethiopian local number, which
|
||||
// is always 9 digits after the trunk zero.
|
||||
if (/^77\d{6}$/.test(bare)) return `+253${bare}`;
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
// it looks like a full international number, else leave as typed.
|
||||
return digits.length >= 11 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||
* the carrier integration is domestic-only, so a send to anything else is
|
||||
* queued and silently lost. Callers use this to fall back to email instead of
|
||||
* pretending an SMS is on its way.
|
||||
* Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
|
||||
*
|
||||
* The gateway itself is opaque from here — `SmsClientService` publishes to
|
||||
* RabbitMQ and the carrier sits several hops downstream — so this list is a
|
||||
* policy statement, not a capability probe: a number outside it is treated as
|
||||
* unreachable and callers fall back to email rather than promising an SMS that
|
||||
* would be queued and silently dropped.
|
||||
*
|
||||
* - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses
|
||||
* as a valid ET number but is not a range this gateway delivers to.
|
||||
* - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line).
|
||||
*/
|
||||
const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/];
|
||||
|
||||
/**
|
||||
* Whether a phone sits in a mobile range the SMS gateway can actually reach.
|
||||
*
|
||||
* Named "domestic" for the Ethiopian-only era this predates; it now covers both
|
||||
* countries the railway runs through. Callers use it to fall back to email
|
||||
* instead of pretending an SMS is on its way.
|
||||
*/
|
||||
export function isDomesticPhone(rawPhone: string): boolean {
|
||||
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||
const normalized = normalizePhone(rawPhone);
|
||||
return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +121,7 @@ export class OtpService {
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate OTP
|
||||
@@ -197,8 +219,10 @@ export class OtpService {
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@@ -222,7 +246,8 @@ export class OtpService {
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — no transport reported hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
@@ -247,7 +272,8 @@ export class OtpService {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -330,8 +356,9 @@ export class OtpService {
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
|
||||
|
||||
@@ -3967,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedulingStatus: "SCHEDULED",
|
||||
scheduledAt: new Date(),
|
||||
wagonsRequired,
|
||||
// Pinned for cancellation pricing: unassign clears wagonsRequired, this
|
||||
// stays. Written once — a later re-allocation keeps the first stamp.
|
||||
...(Number(booking.cancellationWagons ?? 0) > 0
|
||||
? {}
|
||||
: { cancellationWagons: wagonsRequired }),
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
|
||||
@@ -39,15 +39,30 @@ export class BookingNotifierService {
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
if (!s) return fallback;
|
||||
const ref = s.reference ?? s.trainNumber ?? null;
|
||||
const route =
|
||||
// Customers know the train by its operating number (8001), not the
|
||||
// schedule reference — lead with it and keep S-… as the secondary id.
|
||||
const parts = [
|
||||
s.reference,
|
||||
s.originStation?.label && s.destinationStation?.label
|
||||
? ` (${s.originStation.label} → ${s.destinationStation.label})`
|
||||
: '';
|
||||
? `${s.originStation.label} → ${s.destinationStation.label}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const detail = parts.length ? ` (${parts.join(', ')})` : '';
|
||||
const departure = s.scheduledDepartureDate
|
||||
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
|
||||
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})} EAT`
|
||||
: '';
|
||||
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
|
||||
const number = s.trainNumber ?? s.reference ?? null;
|
||||
return number
|
||||
? `train ${number}${number === s.reference ? '' : detail}${departure}`
|
||||
: `${fallback}${detail}${departure}`;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
||||
|
||||
@@ -2320,12 +2320,18 @@ export class TrainSchedulingService {
|
||||
// batch fill, which unlinks it and frees its wagons on the next window cycle.
|
||||
const scheduledAt = new Date();
|
||||
for (const booking of bookings) {
|
||||
const wagonsRequired = sumWagonsRequired(booking, wagonPlan);
|
||||
await this.bookingsRepository.updateSchedulingFields(
|
||||
booking.id,
|
||||
{
|
||||
schedulingStatus: SchedulingStatus.Scheduled,
|
||||
scheduledAt,
|
||||
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
|
||||
wagonsRequired,
|
||||
// Pinned for cancellation pricing: unassign clears wagonsRequired,
|
||||
// this stays. Written once — re-allocation keeps the first stamp.
|
||||
...(Number(booking.cancellationWagons ?? 0) > 0
|
||||
? {}
|
||||
: { cancellationWagons: wagonsRequired }),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTransitAgentDto {
|
||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
||||
@ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-01' })
|
||||
@ApiProperty({ example: "2026-01-01" })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31' })
|
||||
@ApiProperty({ example: "2026-12-31" })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
|
||||
@@ -28,4 +37,33 @@ export class CreateTransitAgentDto {
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
/**
|
||||
* Becomes the IAM account's email and is where the activation link is sent.
|
||||
* Optional: an agent may be created as a GL-assignable roster entry only, and
|
||||
* invited later. Supplying it creates the portal account right away.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: "a.bourhan@transit.dj" })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "+25377834567",
|
||||
description:
|
||||
"E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
/** Login name. Defaults to the email, which is what the agent tries first. */
|
||||
@ApiPropertyOptional({ example: "a-bourhan" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
/**
|
||||
* Give an EXISTING roster-only transit agent a portal login.
|
||||
*
|
||||
* Email is required here even though it is optional on the agent itself: this
|
||||
* endpoint's whole job is to send the activation link, and email is the only
|
||||
* channel guaranteed to reach a Djibouti-registered officer. Omitting a field
|
||||
* keeps whatever the agent already has.
|
||||
*/
|
||||
export class InviteTransitAgentDto {
|
||||
@ApiProperty({ example: "a.bourhan@transit.dj" })
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "+25377834567",
|
||||
description:
|
||||
"E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "a-bourhan" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
import { CreateTransitAgentDto } from "./create-transit-agent.dto";
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
@@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
||||
@Index(['isActive'])
|
||||
@Entity({ schema: "freight", name: "transit_agents" })
|
||||
@Index(["isActive"])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
@Column({ name: "name", type: "varchar", length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'date' })
|
||||
@Column({ name: "valid_from", type: "date" })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: 'valid_to', type: 'date' })
|
||||
@Column({ name: "valid_to", type: "date" })
|
||||
validTo!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/**
|
||||
* The IAM account (`iam.users`, userType `individual`) that signs in to the
|
||||
* portal as this agent. No FK: `iam` is a separate schema owned by the IAM
|
||||
* service, and the rest of the codebase reaches it by query rather than by
|
||||
* relation.
|
||||
*
|
||||
* NULL for every agent that exists only as a GL-assignable roster entry —
|
||||
* which is all of them before this feature, and stays legal afterwards. An
|
||||
* agent gains an account when staff invite it, so `userId !== null` IS the
|
||||
* "has a portal login" predicate; nothing else needs to track it.
|
||||
*/
|
||||
@Column({ name: "user_id", type: "uuid", nullable: true })
|
||||
userId?: string | null;
|
||||
|
||||
/**
|
||||
* Mirrors the IAM account's email; the activation link is sent here. Nullable
|
||||
* because a roster-only agent has never needed one — but an invite cannot be
|
||||
* sent without it, so {@link TransitAgentsService.invite} requires it.
|
||||
*/
|
||||
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||
phoneNumber?: string | null;
|
||||
}
|
||||
|
||||
@@ -10,36 +10,38 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
} from "../../common/rule-engine-guards";
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
@ApiTags('transit-agents')
|
||||
@Controller('transit-agents')
|
||||
@ApiTags("transit-agents")
|
||||
@Controller("transit-agents")
|
||||
@ApiBearerAuth()
|
||||
export class TransitAgentsController {
|
||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents' })
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "List transit agents" })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.transitAgentsService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
query.isActive === "all"
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
? query.isActive === "true"
|
||||
: undefined,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
@@ -49,39 +51,77 @@ export class TransitAgentsController {
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
@Get('assignable')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
||||
@Get("assignable")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "List transit agents assignable right now (active and in-window)",
|
||||
})
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('transit-agents')
|
||||
@ApiOperation({ summary: 'Create a transit agent' })
|
||||
@RuleEngineCreate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a transit agent; with an email, also creates its portal account and sends the activation link",
|
||||
})
|
||||
create(@Body() dto: CreateTransitAgentDto) {
|
||||
return this.transitAgentsService.create(dto);
|
||||
return this.transitAgentsService.createWithInvite(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('transit-agents')
|
||||
@ApiOperation({ summary: 'Update a transit agent' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
||||
/**
|
||||
* The path for the roster entries already in production: they were created
|
||||
* before transit agents had logins, so they get their account here rather
|
||||
* than at create time.
|
||||
*/
|
||||
@Post(":id/invite")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a portal account for an existing transit agent and send the activation link",
|
||||
})
|
||||
invite(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: InviteTransitAgentDto,
|
||||
) {
|
||||
return this.transitAgentsService.invite(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/resend-activation")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "Resend a transit agent's activation / password-reset link",
|
||||
})
|
||||
resendActivation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
return this.transitAgentsService.resendActivation(id, dto.channel);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({ summary: "Update a transit agent" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTransitAgentDto,
|
||||
) {
|
||||
return this.transitAgentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('transit-agents')
|
||||
@Delete(":id")
|
||||
@RuleEngineDelete("transit-agents")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Soft-delete a transit agent" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsController } from './transit-agents.controller';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsController } from "./transit-agents.controller";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransitAgent])],
|
||||
imports: [
|
||||
// `User` is registered here so this module can create the IAM account that
|
||||
// backs an invited transit agent, in the same transaction as the agent row.
|
||||
TypeOrmModule.forFeature([TransitAgent, User]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [TransitAgentsController],
|
||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EntityManager,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
order: { name: 'ASC' },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The transit agent signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<TransitAgent | null> {
|
||||
return this.repository.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive, matching the `lower(email)` unique index. `exceptId` lets
|
||||
* an update re-save its own address without colliding with itself.
|
||||
*/
|
||||
async existsByEmail(email: string, exceptId?: string): Promise<boolean> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("ta")
|
||||
.where("lower(ta.email) = lower(:email)", { email });
|
||||
if (exceptId) qb.andWhere("ta.id != :exceptId", { exceptId });
|
||||
return (await qb.getCount()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert inside a caller-supplied transaction, so the agent row and the IAM
|
||||
* user it points at commit together — a row referencing a user that was
|
||||
* rolled back (or vice versa) is an account nobody can sign in to.
|
||||
*/
|
||||
createInTransaction(
|
||||
manager: EntityManager,
|
||||
data: Partial<TransitAgent>,
|
||||
): Promise<TransitAgent> {
|
||||
const repo = manager.getRepository(TransitAgent);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
/** Attach an IAM account to an existing agent, inside the caller's transaction. */
|
||||
async linkAccountInTransaction(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
data: Pick<TransitAgent, "userId" | "email" | "phoneNumber">,
|
||||
): Promise<TransitAgent> {
|
||||
const repo = manager.getRepository(TransitAgent);
|
||||
await repo.update(id, data);
|
||||
return repo.findOneOrFail({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
/**
|
||||
* The account half of a transit agent. The roster half (validity window,
|
||||
* assignability) predates this and is untouched — what these lock is that
|
||||
* adding a login did not make an account MANDATORY, since production is full of
|
||||
* roster-only agents that must keep working.
|
||||
*/
|
||||
describe("TransitAgentsService accounts", () => {
|
||||
const savedUser = { id: "user-1" };
|
||||
|
||||
let repo: {
|
||||
existsByEmail: jest.Mock;
|
||||
createInTransaction: jest.Mock;
|
||||
linkAccountInTransaction: jest.Mock;
|
||||
findById: jest.Mock;
|
||||
findByUserId: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let userRepository: { findOne: jest.Mock; update: jest.Mock };
|
||||
let customerResetService: {
|
||||
sendResetLinkToUser: jest.Mock;
|
||||
sendResetLinkToUserOnChannels: jest.Mock;
|
||||
};
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
|
||||
let service: TransitAgentsService;
|
||||
|
||||
const base = {
|
||||
name: "Ahmed Bourhan",
|
||||
validFrom: "2026-01-01",
|
||||
validTo: "2026-12-31",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepoInTx = {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn().mockResolvedValue(savedUser),
|
||||
};
|
||||
|
||||
repo = {
|
||||
existsByEmail: jest.fn().mockResolvedValue(false),
|
||||
createInTransaction: jest.fn(async (_m, data) => ({
|
||||
id: "ta-1",
|
||||
...data,
|
||||
})),
|
||||
linkAccountInTransaction: jest.fn(async (_m, id, data) => ({
|
||||
id,
|
||||
...base,
|
||||
isActive: true,
|
||||
...data,
|
||||
})),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
create: jest.fn(async (data) => ({ id: "ta-1", ...data })),
|
||||
// `BaseRepository.update` re-reads the row via `findById`, so the result
|
||||
// carries columns the caller never passed — `userId` above all, which is
|
||||
// what decides whether IAM gets synced.
|
||||
update: jest.fn(async (id, data) => ({
|
||||
...(await repo.findById(id)),
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
userRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
};
|
||||
customerResetService = {
|
||||
sendResetLinkToUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
maskedTarget: "a**@transit.dj",
|
||||
channel: ResetChannel.Email,
|
||||
}),
|
||||
sendResetLinkToUserOnChannels: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ maskedTarget: "a**@transit.dj", channel: ResetChannel.Email },
|
||||
]),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) =>
|
||||
cb({ getRepository: () => userRepoInTx } as never),
|
||||
),
|
||||
};
|
||||
|
||||
service = new TransitAgentsService(
|
||||
repo as never,
|
||||
userRepository as never,
|
||||
customerResetService as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a roster-only agent with no account when no email is given", async () => {
|
||||
const { agent, activationSentTo } = await service.createWithInvite(base);
|
||||
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
customerResetService.sendResetLinkToUserOnChannels,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(agent.hasAccount).toBe(false);
|
||||
expect(activationSentTo).toBeNull();
|
||||
});
|
||||
|
||||
it("creates the IAM account with no password set when an email is given", async () => {
|
||||
await service.createWithInvite({
|
||||
...base,
|
||||
email: "A.Bourhan@Transit.DJ",
|
||||
});
|
||||
|
||||
expect(userRepoInTx.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: "a.bourhan@transit.dj",
|
||||
username: "a.bourhan@transit.dj",
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the activation link only after the transaction commits", async () => {
|
||||
const order: string[] = [];
|
||||
dataSource.transaction.mockImplementation(
|
||||
async (cb: (m: unknown) => unknown) => {
|
||||
const result = await cb({ getRepository: () => userRepoInTx });
|
||||
order.push("commit");
|
||||
return result;
|
||||
},
|
||||
);
|
||||
customerResetService.sendResetLinkToUserOnChannels.mockImplementation(
|
||||
async () => {
|
||||
order.push("send");
|
||||
return [
|
||||
{ maskedTarget: "a**@transit.dj", channel: ResetChannel.Email },
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
await service.createWithInvite({ ...base, email: "a@transit.dj" });
|
||||
|
||||
expect(order).toEqual(["commit", "send"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invite", () => {
|
||||
it("attaches an account to an existing roster-only agent and sends the link", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const { agent, activationSentTo } = await service.invite("ta-1", {
|
||||
email: "a@transit.dj",
|
||||
});
|
||||
|
||||
expect(repo.linkAccountInTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"ta-1",
|
||||
expect.objectContaining({ userId: "user-1", email: "a@transit.dj" }),
|
||||
);
|
||||
expect(agent.hasAccount).toBe(true);
|
||||
expect(activationSentTo).toBe("a**@transit.dj");
|
||||
});
|
||||
|
||||
it("refuses to mint a second account for an agent that already has one", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: "user-9",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.invite("ta-1", { email: "a@transit.dj" }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses credentials that already belong to another account", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
userRepository.findOne.mockResolvedValue({ id: "someone-else" });
|
||||
|
||||
await expect(
|
||||
service.invite("ta-1", { email: "a@transit.dj" }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("texts the link as well when the number is domestic", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await service.invite("ta-1", {
|
||||
email: "a@transit.dj",
|
||||
phoneNumber: "+251911223344",
|
||||
});
|
||||
|
||||
expect(
|
||||
customerResetService.sendResetLinkToUserOnChannels,
|
||||
).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
[ResetChannel.Email, ResetChannel.Phone],
|
||||
expect.objectContaining({ allowWithoutCredential: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await service.invite("ta-1", {
|
||||
email: "a@transit.dj",
|
||||
phoneNumber: "+33612345678",
|
||||
});
|
||||
|
||||
expect(
|
||||
customerResetService.sendResetLinkToUserOnChannels,
|
||||
).toHaveBeenCalledWith("user-1", [ResetChannel.Email], expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("mirrors an edited email onto the linked IAM account", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await service.update("ta-1", { email: "New@Transit.DJ" });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
"ta-1",
|
||||
expect.objectContaining({ email: "new@transit.dj" }),
|
||||
);
|
||||
expect(userRepository.update).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ email: "new@transit.dj" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("never writes username — it names an IAM account, not a column on this table", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await service.update("ta-1", { username: "nope" } as never);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
"ta-1",
|
||||
expect.not.objectContaining({ username: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves IAM alone for a roster-only agent", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await service.update("ta-1", { email: "a@transit.dj" });
|
||||
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resendActivation", () => {
|
||||
it("refuses for an agent that has no account yet", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resendActivation("ta-1", ResetChannel.Email),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses an SMS resend to a foreign number", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
phoneNumber: "+33612345678",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resendActivation("ta-1", ResetChannel.Phone),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("reuses the existing account rather than minting a new one", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
email: "a@transit.dj",
|
||||
});
|
||||
|
||||
await service.resendActivation("ta-1", ResetChannel.Email);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
ResetChannel.Email,
|
||||
expect.objectContaining({ allowWithoutCredential: true }),
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,49 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindOptionsOrder,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
|
||||
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
||||
hasAccount: boolean;
|
||||
};
|
||||
|
||||
export interface InvitedTransitAgent {
|
||||
agent: TransitAgentView;
|
||||
/** Masked destination of the activation link, or null if none was sent. */
|
||||
activationSentTo: string | null;
|
||||
activationChannel: ResetChannel | null;
|
||||
}
|
||||
|
||||
type TransitAgentListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
@@ -25,20 +57,34 @@ function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
|
||||
function validityStatus(
|
||||
agent: Pick<TransitAgent, "validFrom" | "validTo">,
|
||||
): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
||||
if (today > agent.validTo) return 'EXPIRED';
|
||||
return 'VALID';
|
||||
if (today < agent.validFrom) return "NOT_STARTED";
|
||||
if (today > agent.validTo) return "EXPIRED";
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
return { ...agent, validityStatus: validityStatus(agent) };
|
||||
return {
|
||||
...agent,
|
||||
validityStatus: validityStatus(agent),
|
||||
hasAccount: Boolean(agent.userId),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsService {
|
||||
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
|
||||
private readonly logger = new Logger(TransitAgentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||
data: TransitAgentView[];
|
||||
@@ -46,10 +92,13 @@ export class TransitAgentsService {
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
|
||||
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
|
||||
filter.sortBy ?? "",
|
||||
)
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: 'name';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
: "name";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||
|
||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
@@ -86,12 +135,14 @@ export class TransitAgentsService {
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new BadRequestException('Selected transit officer was not found.');
|
||||
throw new BadRequestException("Selected transit officer was not found.");
|
||||
}
|
||||
if (!agent.isActive) {
|
||||
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
|
||||
throw new BadRequestException(
|
||||
`${agent.name} is suspended — pick another transit officer.`,
|
||||
);
|
||||
}
|
||||
if (validityStatus(agent) !== 'VALID') {
|
||||
if (validityStatus(agent) !== "VALID") {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
@@ -99,38 +150,364 @@ export class TransitAgentsService {
|
||||
return agent;
|
||||
}
|
||||
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
/**
|
||||
* Create an IAM account for a transit agent, inside the caller's transaction.
|
||||
*
|
||||
* Follows `ShippingLineCompaniesService.register` — same entities, same shape
|
||||
* — including its one deliberate difference from employee creation: no
|
||||
* `UserCredential` row is written and `hasSetPassword` stays false, so the
|
||||
* agent must come through the activation link. Staff never handle a password.
|
||||
*/
|
||||
private async createIamAccount(
|
||||
manager: EntityManager,
|
||||
args: {
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email: args.email,
|
||||
username: args.username,
|
||||
phoneNumber: args.phoneNumber,
|
||||
name: { en: args.name },
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
// No credential row: the account has no password until the activation
|
||||
// link is used. `hasSetPassword` must stay false or the portal treats
|
||||
// the account as ready to sign in with a password that does not exist.
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
return user.id as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate the account fields shared by create and invite, and
|
||||
* refuse credentials that already belong to somebody.
|
||||
*/
|
||||
private async prepareAccountFields(
|
||||
dto: { email: string; phoneNumber?: string; username?: string },
|
||||
exceptAgentId?: string,
|
||||
) {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = (dto.username?.trim() || email).toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
|
||||
if (
|
||||
await this.transitAgentsRepository.existsByEmail(email, exceptAgentId)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`A transit agent with email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.create({
|
||||
|
||||
// An existing IAM account means these credentials already belong to a
|
||||
// customer, a shipping line or an employee. Reusing it would let one login
|
||||
// resolve to two different account kinds, so this is refused rather than
|
||||
// merged.
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException("email_or_username_already_in_use");
|
||||
}
|
||||
|
||||
return { email, username, phoneNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transit agent.
|
||||
*
|
||||
* With no `email` this is the pre-existing behaviour: a GL-assignable roster
|
||||
* entry with no login, which is what production is full of. With an `email`
|
||||
* the IAM account and the agent row are created in one transaction and the
|
||||
* activation link goes out.
|
||||
*/
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
return (await this.createWithInvite(dto)).agent;
|
||||
}
|
||||
|
||||
/** {@link create}, also reporting where the activation link went. */
|
||||
async createWithInvite(
|
||||
dto: CreateTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
|
||||
if (!dto.email) {
|
||||
// Roster-only agent — no account, nothing to send.
|
||||
const agent = await this.transitAgentsRepository.create(base);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: null,
|
||||
activationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields({
|
||||
email: dto.email,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
username: dto.username,
|
||||
});
|
||||
return withValidityStatus(agent);
|
||||
|
||||
const agent = await this.dataSource.transaction(async (manager) => {
|
||||
const userId = await this.createIamAccount(manager, {
|
||||
name: base.name,
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
});
|
||||
return this.transitAgentsRepository.createInTransaction(manager, {
|
||||
...base,
|
||||
userId,
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||
// a registered agent. The link is resendable, and the account is already
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
|
||||
/**
|
||||
* Give an EXISTING agent a portal login — the path for the roster entries
|
||||
* already in production. Creates the IAM account, attaches it, and sends the
|
||||
* activation link.
|
||||
*/
|
||||
async invite(
|
||||
id: string,
|
||||
dto: InviteTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
const current = await this.transitAgentsRepository.findById(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
if (current.userId) {
|
||||
// Already has an account — resending is `resendActivation`, which reuses
|
||||
// the existing user instead of minting a second one for the same person.
|
||||
throw new ConflictException(
|
||||
"This transit agent already has a portal account — resend the activation link instead.",
|
||||
);
|
||||
}
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
||||
dto,
|
||||
id,
|
||||
);
|
||||
|
||||
const agent = await this.dataSource.transaction(async (manager) => {
|
||||
const userId = await this.createIamAccount(manager, {
|
||||
name: current.name,
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
});
|
||||
return this.transitAgentsRepository.linkAccountInTransaction(
|
||||
manager,
|
||||
id,
|
||||
{
|
||||
userId,
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the activation link.
|
||||
*
|
||||
* Email always goes out — it is the only channel guaranteed to reach a
|
||||
* foreign-registered officer. SMS is sent in addition when the number is
|
||||
* domestic, since the gateway silently drops anything else. Both carry the
|
||||
* SAME single-use ticket: minting retires earlier tickets, so two mints would
|
||||
* kill the email link the moment the SMS went out.
|
||||
*
|
||||
* Reports the email send, as that is the one that is always attempted.
|
||||
*/
|
||||
async sendActivationLink(agent: TransitAgent) {
|
||||
if (!agent.userId) return null;
|
||||
|
||||
const scope = `transit agent ${agent.id}`;
|
||||
const channels = [ResetChannel.Email];
|
||||
if (agent.phoneNumber && isDomesticPhone(agent.phoneNumber)) {
|
||||
channels.push(ResetChannel.Phone);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
|
||||
agent.userId,
|
||||
channels,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
|
||||
|
||||
if (!emailed) {
|
||||
this.logger.error(
|
||||
`Activation email not sent for transit agent ${agent.id} — no reachable address`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
channels.includes(ResetChannel.Phone) &&
|
||||
!sent.some((s) => s.channel === ResetChannel.Phone)
|
||||
) {
|
||||
this.logger.warn(`Activation SMS not sent for transit agent ${agent.id}`);
|
||||
}
|
||||
|
||||
return emailed;
|
||||
}
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new NotFoundException("Transit agent not found");
|
||||
}
|
||||
if (!agent.userId) {
|
||||
throw new BadRequestException(
|
||||
"This transit agent has no portal account yet — invite them first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
channel === ResetChannel.Phone &&
|
||||
(!agent.phoneNumber || !isDomesticPhone(agent.phoneNumber))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This transit agent has no domestic phone number — the SMS gateway cannot reach it",
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||
agent.userId,
|
||||
channel,
|
||||
{
|
||||
scope: `transit agent ${agent.id}`,
|
||||
allowWithoutCredential: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active account with ${
|
||||
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||
} for this transit agent`,
|
||||
);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** The transit agent signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<TransitAgent | null> {
|
||||
return this.transitAgentsRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTransitAgentDto,
|
||||
): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
// `username` only ever names an IAM account, and it is chosen once at
|
||||
// account creation. Accepting it here (PartialType inherits it from the
|
||||
// create DTO) would write a column that does not exist on this table.
|
||||
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
||||
|
||||
const contact: Partial<TransitAgent> = {};
|
||||
if (email !== undefined) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
||||
throw new ConflictException(
|
||||
`A transit agent with email ${normalized} already exists`,
|
||||
);
|
||||
}
|
||||
contact.email = normalized;
|
||||
}
|
||||
if (phoneNumber !== undefined) {
|
||||
contact.phoneNumber = phoneNumber.trim() || null;
|
||||
}
|
||||
|
||||
const updated = await this.transitAgentsRepository.update(id, {
|
||||
...dto,
|
||||
...rest,
|
||||
...contact,
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
|
||||
// Keep the IAM account in step. Without this, an agent whose address was
|
||||
// corrected here would still receive its activation link at the old one —
|
||||
// the reset service reads the address off `iam.users`, not off this row.
|
||||
if (
|
||||
updated.userId &&
|
||||
(contact.email !== undefined || contact.phoneNumber !== undefined)
|
||||
) {
|
||||
await this.syncIamContact(updated);
|
||||
}
|
||||
|
||||
return withValidityStatus(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror an edited email/phone onto the linked IAM account.
|
||||
*
|
||||
* Best-effort: a failure here must not fail the agent edit that already
|
||||
* committed, but it does mean the two are out of step, so it is logged loudly
|
||||
* rather than swallowed. Re-running the edit retries it.
|
||||
*/
|
||||
private async syncIamContact(agent: TransitAgent): Promise<void> {
|
||||
if (!agent.userId) return;
|
||||
try {
|
||||
await this.userRepository.update(agent.userId, {
|
||||
...(agent.email ? { email: agent.email } : {}),
|
||||
phoneNumber: agent.phoneNumber ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Transit agent ${agent.id} contact updated but IAM user ${agent.userId} was not — ` +
|
||||
`activation links will still go to the old address: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.transitAgentsRepository.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
export class CreateTransitAssignmentDto {
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
transitAgentId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: TransitAssignmentStatus,
|
||||
default: TransitAssignmentStatus.NotStarted,
|
||||
description:
|
||||
"Assignments normally start NOT_STARTED; pass one only to record work already under way.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
/** Filters for the transit agent's own booking list. */
|
||||
export class MyAssignmentsQueryDto {
|
||||
/** Free text over the booking reference and the customer's company name. */
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "DISPATCHED",
|
||||
description: "The booking's scheduling state.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
// Bounded so a hand-edited query string cannot ask for the whole table.
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/** The portal's Save / Finish action on the agent's own assignment. */
|
||||
export class SubmitTransitAssignmentDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"true finishes the assignment, which also locks its documents. false saves progress and leaves it open.",
|
||||
})
|
||||
// Arrives as a string when posted as multipart alongside files.
|
||||
@Transform(({ value }) =>
|
||||
value === "true" ? true : value === "false" ? false : value,
|
||||
)
|
||||
@IsBoolean()
|
||||
finish!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
export class TransitAssignmentQueryDto {
|
||||
@ApiPropertyOptional({ format: "uuid" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "uuid" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
/**
|
||||
* `bookingId` and `transitAgentId` are absent on purpose: repointing an
|
||||
* assignment at a different booking or agent would silently reattribute the
|
||||
* work and the documents already filed under it. Delete and re-create instead.
|
||||
*/
|
||||
export class UpdateTransitAssignmentDto {
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
|
||||
/** Where the agent's work on this booking currently stands. */
|
||||
export enum TransitAssignmentStatus {
|
||||
NotStarted = "NOT_STARTED",
|
||||
InProgress = "IN_PROGRESS",
|
||||
Finished = "FINISHED",
|
||||
}
|
||||
|
||||
/**
|
||||
* One transit agent's work on one booking. An agent handles many bookings, so
|
||||
* this is the join between the two, carrying the work's own state: when it
|
||||
* started, when it finished, and the documents produced along the way.
|
||||
*
|
||||
* Deliberately separate from the transit-assignee handshake on the booking
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`), which is a pre-declaration
|
||||
* agreement between GL Ethiopia and GL Djibouti about WHO will handle customs.
|
||||
* Nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no stored duration. "Time after the train arrives" is
|
||||
* `finishedAt − booking.arrivedAt`; both halves already exist, and storing the
|
||||
* difference would be a third source of truth that goes stale the moment either
|
||||
* timestamp is corrected. It is computed on read — see
|
||||
* `TransitAssignmentsService.toView`.
|
||||
*
|
||||
* Documents live in `freight.files` under
|
||||
* {@link TRANSIT_ASSIGNMENT_FILE_RESOURCE}, which already carries the MinIO
|
||||
* object, the upload time, the uploader and the supersede history.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "transit_assignments" })
|
||||
@Index(["bookingId"])
|
||||
@Index(["transitAgentId", "status"])
|
||||
export class TransitAssignment extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: "transit_agent_id", type: "uuid" })
|
||||
transitAgentId!: string;
|
||||
|
||||
@ManyToOne(() => TransitAgent)
|
||||
@JoinColumn({ name: "transit_agent_id" })
|
||||
transitAgent?: TransitAgent;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: TransitAssignmentStatus.NotStarted,
|
||||
})
|
||||
status!: TransitAssignmentStatus;
|
||||
|
||||
/** Stamped on the first move to IN_PROGRESS; never overwritten afterwards. */
|
||||
@Column({ name: "started_at", type: "timestamptz", nullable: true })
|
||||
startedAt?: Date | null;
|
||||
|
||||
/** Stamped on the move to FINISHED. Cleared if the work is reopened. */
|
||||
@Column({ name: "finished_at", type: "timestamptz", nullable: true })
|
||||
finishedAt?: Date | null;
|
||||
|
||||
@Column({ name: "assigned_by_user_id", type: "uuid", nullable: true })
|
||||
assignedByUserId?: string | null;
|
||||
|
||||
@Column({ name: "assigned_at", type: "timestamptz", default: () => "now()" })
|
||||
assignedAt!: Date;
|
||||
|
||||
@Column({ name: "note", type: "text", nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** `files.resource` value for documents attached to a transit assignment. */
|
||||
export const TRANSIT_ASSIGNMENT_FILE_RESOURCE = "transit_assignments";
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
|
||||
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
|
||||
import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto";
|
||||
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
|
||||
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
/**
|
||||
* Transit assignments — one transit agent's work on one booking.
|
||||
*
|
||||
* Distinct from the transit-assignee handshake under
|
||||
* `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle
|
||||
* a shipment's customs. This is the work record that follows: status, timings
|
||||
* and documents.
|
||||
*/
|
||||
@ApiTags("transit-assignments")
|
||||
@Controller("transit-assignments")
|
||||
@ApiBearerAuth()
|
||||
export class TransitAssignmentsController {
|
||||
constructor(
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
) {}
|
||||
|
||||
// ── Portal — the signed-in transit agent's own work ───────────────────────
|
||||
// Declared first so the literal `my` segment is matched before `:id`.
|
||||
// Every route resolves the agent from the session; none accepts an agent id.
|
||||
|
||||
@Get("my/stats")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Dashboard figures for the signed-in transit agent's own work",
|
||||
})
|
||||
myStats(@CurrentUser() user: TCurrentUser) {
|
||||
return this.transitAssignmentsService.myStats(user.id);
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The signed-in transit agent's assigned bookings (paginated, filterable)",
|
||||
})
|
||||
findMine(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query() query: MyAssignmentsQueryDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.findMine(user.id, query);
|
||||
}
|
||||
|
||||
@Get("my/:id")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "One of my assignments, with its documents" })
|
||||
findMineById(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.findMineById(user.id, id);
|
||||
}
|
||||
|
||||
@Post("my/:id/files")
|
||||
@PortalCustomer()
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.",
|
||||
})
|
||||
uploadMyFiles(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
// One `titles` part per file, in the same order. A single-file upload posts
|
||||
// one part, which multipart parsing hands back as a bare string rather than
|
||||
// an array — normalised here so the service always sees a positional list.
|
||||
@Body("titles") titles?: string | string[],
|
||||
) {
|
||||
return this.transitAssignmentsService.uploadMyFiles(
|
||||
user.id,
|
||||
id,
|
||||
files,
|
||||
{ userId: user.id, name: user.name?.en ?? undefined },
|
||||
titles === undefined ? undefined : ([] as string[]).concat(titles),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("my/:id/files/:fileId")
|
||||
@PortalCustomer()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a document from my assignment" })
|
||||
removeMyFile(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.removeMyFile(user.id, id, fileId);
|
||||
}
|
||||
|
||||
@Post("my/:id/submit")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Save progress, or finish the assignment (which locks its documents)",
|
||||
})
|
||||
submitMine(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SubmitTransitAssignmentDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.submitMine(user.id, id, dto);
|
||||
}
|
||||
|
||||
// ── Backoffice ────────────────────────────────────────────────────────────
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List transit assignments (paginated, filterable by booking / agent / status)",
|
||||
})
|
||||
findAll(@Query() query: TransitAssignmentQueryDto) {
|
||||
return this.transitAssignmentsService.findAll(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared before `:id` — Nest matches routes in order, so a literal segment
|
||||
* registered after a parameter would be swallowed by it.
|
||||
*/
|
||||
@Get("by-agent/:transitAgentId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "Every assignment handed to one transit agent" })
|
||||
findByTransitAgent(
|
||||
@Param("transitAgentId", ParseUUIDPipe) transitAgentId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.findByTransitAgent(transitAgentId);
|
||||
}
|
||||
|
||||
@Get("by-booking/:bookingId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "Every transit agent assigned to one booking" })
|
||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
return this.transitAssignmentsService.findByBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"One assignment, with its attached documents and computed duration",
|
||||
})
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.create)
|
||||
@ApiOperation({ summary: "Assign a transit agent to a booking" })
|
||||
create(
|
||||
@Body() dto: CreateTransitAssignmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitAssignmentsService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Update status or note — status changes stamp the start/finish clocks",
|
||||
})
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTransitAssignmentDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Soft-delete an assignment" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.remove(id);
|
||||
}
|
||||
|
||||
// ── Documents ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(":id/files")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "An assignment's uploaded documents" })
|
||||
listFiles(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.listFiles(id);
|
||||
}
|
||||
|
||||
@Post(":id/files")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload one or more documents; re-uploading adds a version, it does not overwrite",
|
||||
})
|
||||
uploadFiles(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body("titles") titles?: string | string[],
|
||||
) {
|
||||
return this.transitAssignmentsService.uploadFiles(
|
||||
id,
|
||||
files,
|
||||
{ userId: user?.id, name: user?.name?.en ?? undefined },
|
||||
titles === undefined ? undefined : ([] as string[]).concat(titles),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/files/:fileId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove one document from an assignment" })
|
||||
removeFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.removeFile(id, fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { TransitAssignment } from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsController } from "./transit-assignments.controller";
|
||||
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// `Booking` is registered as an ENTITY rather than importing BookingsModule:
|
||||
// this module only confirms a booking id exists, and that module would drag
|
||||
// its whole graph (billing, contracts, scheduling, first/last mile) along.
|
||||
TypeOrmModule.forFeature([TransitAssignment, Booking]),
|
||||
FilesModule,
|
||||
TransitAgentsModule,
|
||||
],
|
||||
controllers: [TransitAssignmentsController],
|
||||
providers: [TransitAssignmentsService, TransitAssignmentsRepository],
|
||||
exports: [TransitAssignmentsService, TransitAssignmentsRepository],
|
||||
})
|
||||
export class TransitAssignmentsModule {}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
|
||||
export interface TransitAssignmentFilter {
|
||||
bookingId?: string;
|
||||
transitAgentId?: string;
|
||||
status?: TransitAssignmentStatus;
|
||||
/** The booking's scheduling state (DISPATCHED / SCHEDULED / …). */
|
||||
schedulingStatus?: string;
|
||||
/** Free text over the booking reference and the customer's company name. */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAssignmentsRepository extends BaseRepository<TransitAssignment> {
|
||||
constructor(
|
||||
@InjectRepository(TransitAssignment)
|
||||
private readonly assignmentsRepo: Repository<TransitAssignment>,
|
||||
) {
|
||||
super(assignmentsRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking is joined rather than lazily loaded because every read needs
|
||||
* its `arrivedAt` — that is the other half of the computed
|
||||
* "time after the train arrives", so a list without it would be N+1 queries
|
||||
* or a column of nulls. The customer's company rides along for the same
|
||||
* reason: the agent's list is read by reference AND by whose cargo it is.
|
||||
*/
|
||||
private baseQuery() {
|
||||
return this.assignmentsRepo
|
||||
.createQueryBuilder("ta")
|
||||
.leftJoinAndSelect("ta.booking", "booking")
|
||||
.leftJoinAndSelect("booking.company", "company")
|
||||
.leftJoinAndSelect("ta.transitAgent", "agent")
|
||||
.where("ta.deletedAt IS NULL");
|
||||
}
|
||||
|
||||
/** Shared filter application, so a list and its count can never diverge. */
|
||||
private applyFilters(
|
||||
qb: ReturnType<TransitAssignmentsRepository["baseQuery"]>,
|
||||
filter: TransitAssignmentFilter,
|
||||
) {
|
||||
if (filter.bookingId) {
|
||||
qb.andWhere("ta.bookingId = :bookingId", { bookingId: filter.bookingId });
|
||||
}
|
||||
if (filter.transitAgentId) {
|
||||
qb.andWhere("ta.transitAgentId = :transitAgentId", {
|
||||
transitAgentId: filter.transitAgentId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("ta.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.schedulingStatus) {
|
||||
qb.andWhere("booking.schedulingStatus = :schedulingStatus", {
|
||||
schedulingStatus: filter.schedulingStatus,
|
||||
});
|
||||
}
|
||||
if (filter.search?.trim()) {
|
||||
qb.andWhere(
|
||||
"(booking.reference ILIKE :search OR company.name ILIKE :search)",
|
||||
{ search: `%${filter.search.trim()}%` },
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findPaginated(
|
||||
filter: TransitAssignmentFilter,
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[TransitAssignment[], number]> {
|
||||
return this.applyFilters(this.baseQuery(), filter)
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's own list, filtered and paginated. Differs from
|
||||
* {@link findPaginated} only in that the agent is pinned by the caller from
|
||||
* the session, so it can never be widened by a query parameter.
|
||||
*/
|
||||
async findByTransitAgentPaginated(
|
||||
transitAgentId: string,
|
||||
filter: Omit<TransitAssignmentFilter, "transitAgentId">,
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[TransitAssignment[], number]> {
|
||||
return this.applyFilters(this.baseQuery(), { ...filter, transitAgentId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
findOneWithRelations(id: string): Promise<TransitAssignment | null> {
|
||||
return this.baseQuery().andWhere("ta.id = :id", { id }).getOne();
|
||||
}
|
||||
|
||||
/** Every live assignment for one agent — the agent's own workload list. */
|
||||
findByTransitAgent(transitAgentId: string): Promise<TransitAssignment[]> {
|
||||
return this.baseQuery()
|
||||
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every live assignment on one booking. */
|
||||
findByBooking(bookingId: string): Promise<TransitAssignment[]> {
|
||||
return this.baseQuery()
|
||||
.andWhere("ta.bookingId = :bookingId", { bookingId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Guards the unique (booking, agent) pair before an insert 23505s. */
|
||||
async existsForPair(
|
||||
bookingId: string,
|
||||
transitAgentId: string,
|
||||
): Promise<boolean> {
|
||||
const count = await this.assignmentsRepo
|
||||
.createQueryBuilder("ta")
|
||||
.where("ta.bookingId = :bookingId", { bookingId })
|
||||
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
|
||||
.andWhere("ta.deletedAt IS NULL")
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import {
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
/**
|
||||
* The two things this module gets wrong quietly: the status transitions that
|
||||
* stamp the clocks, and the duration computed from them. Both are invisible
|
||||
* until a report reads a null or a negative number months later.
|
||||
*/
|
||||
describe("TransitAssignmentsService", () => {
|
||||
const ARRIVED = new Date("2026-08-28T09:00:00Z");
|
||||
|
||||
let assignments: {
|
||||
findPaginated: jest.Mock;
|
||||
findOneWithRelations: jest.Mock;
|
||||
findByTransitAgent: jest.Mock;
|
||||
findByTransitAgentPaginated: jest.Mock;
|
||||
findByBooking: jest.Mock;
|
||||
existsForPair: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
softDelete: jest.Mock;
|
||||
};
|
||||
let agents: { findById: jest.Mock; findByUserId: jest.Mock };
|
||||
let bookings: { findOne: jest.Mock };
|
||||
let files: {
|
||||
findByResource: jest.Mock;
|
||||
findByResourceIdsGrouped: jest.Mock;
|
||||
upload: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
let service: TransitAssignmentsService;
|
||||
|
||||
const row = (over: Partial<TransitAssignment> = {}) =>
|
||||
({
|
||||
id: "ta-1",
|
||||
bookingId: "bk-1",
|
||||
transitAgentId: "ag-1",
|
||||
status: TransitAssignmentStatus.NotStarted,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
// DISPATCHED by default: uploads are gated on it, so a fixture without it
|
||||
// would fail every document test for the wrong reason.
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: ARRIVED,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
},
|
||||
...over,
|
||||
}) as TransitAssignment;
|
||||
|
||||
beforeEach(() => {
|
||||
assignments = {
|
||||
findPaginated: jest.fn(),
|
||||
findOneWithRelations: jest.fn().mockResolvedValue(row()),
|
||||
findByTransitAgent: jest.fn().mockResolvedValue([]),
|
||||
findByTransitAgentPaginated: jest.fn().mockResolvedValue([[], 0]),
|
||||
findByBooking: jest.fn().mockResolvedValue([]),
|
||||
existsForPair: jest.fn().mockResolvedValue(false),
|
||||
create: jest.fn(async (data) => ({ id: "ta-1", ...data })),
|
||||
update: jest.fn(async (id, data) => ({ id, ...data })),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
agents = {
|
||||
findById: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
|
||||
findByUserId: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
|
||||
};
|
||||
bookings = { findOne: jest.fn().mockResolvedValue({ id: "bk-1" }) };
|
||||
files = {
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
findByResourceIdsGrouped: jest.fn().mockResolvedValue(new Map()),
|
||||
upload: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
service = new TransitAssignmentsService(
|
||||
assignments as never,
|
||||
agents as never,
|
||||
bookings as never,
|
||||
files as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("timeAfterTrainArrives", () => {
|
||||
it("reports whole minutes between arrival and finish", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date("2026-08-28T14:30:00Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
const view = await service.findById("ta-1");
|
||||
|
||||
expect(view.timeAfterTrainArrives).toBe(330);
|
||||
});
|
||||
|
||||
it("is null while the work is unfinished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({ status: TransitAssignmentStatus.InProgress, startedAt: ARRIVED }),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
|
||||
});
|
||||
|
||||
it("is null when the booking never recorded an arrival", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date("2026-08-28T14:30:00Z"),
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("status transitions", () => {
|
||||
it("stamps startedAt on the move to IN_PROGRESS", async () => {
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeInstanceOf(Date);
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the ORIGINAL startedAt when finished work is reopened", async () => {
|
||||
const original = new Date("2026-08-28T10:00:00Z");
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
startedAt: original,
|
||||
finishedAt: new Date("2026-08-28T12:00:00Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
// Reopening must not restart the clock, or the elapsed time would only
|
||||
// cover the second attempt rather than the whole job.
|
||||
expect(patch.startedAt).toBe(original);
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("stamps both clocks when finishing work that was never started", async () => {
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeInstanceOf(Date);
|
||||
expect(patch.finishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("clears both clocks on a reset to NOT_STARTED", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
startedAt: ARRIVED,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.NotStarted,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeNull();
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("refuses to assign the same agent to one booking twice", async () => {
|
||||
assignments.existsForPair.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: "bk-1", transitAgentId: "ag-1" }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
expect(assignments.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown booking", async () => {
|
||||
bookings.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: "nope", transitAgentId: "ag-1" }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
it("refuses to delete a file belonging to another assignment", async () => {
|
||||
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
|
||||
|
||||
await expect(service.removeFile("ta-1", "file-2")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names each uploaded file from its positional title", async () => {
|
||||
await service.uploadFiles(
|
||||
"ta-1",
|
||||
[
|
||||
{ originalname: "a.pdf" } as Express.Multer.File,
|
||||
{ originalname: "b.pdf" } as Express.Multer.File,
|
||||
{ originalname: "c.pdf" } as Express.Multer.File,
|
||||
],
|
||||
{},
|
||||
["Bill of lading", " ", "Packing list"],
|
||||
);
|
||||
|
||||
const titles = files.upload.mock.calls.map((call) => call[0].title);
|
||||
// Index N names file N; a blank entry falls back to null so the record
|
||||
// shows its original filename rather than an empty label.
|
||||
expect(titles).toEqual(["Bill of lading", null, "Packing list"]);
|
||||
});
|
||||
|
||||
it("stores no title when none were sent", async () => {
|
||||
await service.uploadFiles(
|
||||
"ta-1",
|
||||
[{ originalname: "a.pdf" } as Express.Multer.File],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(files.upload.mock.calls[0][0].title).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an upload before the booking is dispatched", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an upload once the assignment is finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("refuses to remove a document once the assignment is finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
|
||||
|
||||
await expect(service.removeFile("ta-1", "file-1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("myStats", () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
const withRows = (rows: Record<string, unknown>[]) => {
|
||||
assignments.findByTransitAgent.mockResolvedValue(
|
||||
rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)),
|
||||
);
|
||||
files.findByResourceIdsGrouped.mockResolvedValue(new Map());
|
||||
};
|
||||
|
||||
it("uses the median, so one reopened assignment cannot skew the headline", async () => {
|
||||
withRows([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:35:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T12:10:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:45:00Z"),
|
||||
},
|
||||
// 47h outlier: a mean would report ~12h, which describes nobody.
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-30T08:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
// 95/190/285/2820 -> even count, so the median averages the middle two.
|
||||
// A mean would be 848 minutes, describing none of the four.
|
||||
expect(stats.performance.medianClearanceMinutes).toBe(238);
|
||||
expect(stats.performance.slowestClearanceMinutes).toBe(2820);
|
||||
});
|
||||
|
||||
it("bands clearance times into the SLA buckets", async () => {
|
||||
withRows([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:00:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-29T09:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 });
|
||||
expect(stats.performance.onTimeRate).toBe(67);
|
||||
});
|
||||
|
||||
it("counts coverage only over dispatched bookings", async () => {
|
||||
withRows([
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
// Scheduled bookings cannot receive documents yet, so counting them
|
||||
// would report a failure the agent could not have avoided.
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } },
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.coverage.dispatched).toBe(2);
|
||||
expect(stats.coverage.withDocuments).toBe(0);
|
||||
});
|
||||
|
||||
it("reports nulls rather than zero when nothing has been measured", async () => {
|
||||
withRows([{ status: TransitAssignmentStatus.NotStarted }]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.performance.medianClearanceMinutes).toBeNull();
|
||||
expect(stats.performance.onTimeRate).toBeNull();
|
||||
expect(stats.totals.open).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customerName", () => {
|
||||
it("flattens the booking's company name", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: ARRIVED,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
company: { name: "SHAFICI PHARMACEUTICAL" },
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).customerName).toBe(
|
||||
"SHAFICI PHARMACEUTICAL",
|
||||
);
|
||||
});
|
||||
|
||||
it("is null when the booking has no company", async () => {
|
||||
expect((await service.findById("ta-1")).customerName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUploadDocuments", () => {
|
||||
it("is true for an open assignment on a dispatched booking", async () => {
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(true);
|
||||
});
|
||||
|
||||
it("is false before dispatch", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
|
||||
it("is false once finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portal scoping", () => {
|
||||
it("hides another agent's assignment behind a NotFound", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({ transitAgentId: "someone-else" }),
|
||||
);
|
||||
|
||||
await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an account that is not a transit agent", async () => {
|
||||
agents.findByUserId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findMine("user-1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the query to the session's agent and passes the filters through", async () => {
|
||||
await service.findMine("user-1", {
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [agentId, filter, skip, take] =
|
||||
assignments.findByTransitAgentPaginated.mock.calls[0];
|
||||
// The agent id comes from the session, never from the query — otherwise
|
||||
// one agent could page through another agent's work.
|
||||
expect(agentId).toBe("ag-1");
|
||||
expect(filter).toMatchObject({
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
});
|
||||
expect(skip).toBe(10);
|
||||
expect(take).toBe(10);
|
||||
});
|
||||
|
||||
it("reports pagination meta", async () => {
|
||||
assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]);
|
||||
|
||||
const result = await service.findMine("user-1", { pageSize: 20 });
|
||||
|
||||
expect(result.meta).toEqual({
|
||||
total: 45,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("save moves the assignment to IN_PROGRESS, finish closes it", async () => {
|
||||
await service.submitMine("user-1", "ta-1", { finish: false });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.InProgress,
|
||||
);
|
||||
|
||||
assignments.update.mockClear();
|
||||
await service.submitMine("user-1", "ta-1", { finish: true });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.Finished,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to re-submit an already finished assignment", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitMine("user-1", "ta-1", { finish: true }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
|
||||
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
|
||||
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
|
||||
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
|
||||
import {
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
|
||||
|
||||
/** One attached document, flattened for the API. */
|
||||
export interface TransitAssignmentFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/** When the file was first uploaded. */
|
||||
uploadedAt: string;
|
||||
/** When its metadata was last edited — equal to `uploadedAt` if never. */
|
||||
updatedAt: string;
|
||||
uploadedByUserId: string | null;
|
||||
uploadedByName: string | null;
|
||||
}
|
||||
|
||||
export type TransitAssignmentView = TransitAssignment & {
|
||||
/**
|
||||
* Minutes between the train arriving and the transit work finishing —
|
||||
* `finishedAt − booking.arrivedAt`, floored to whole minutes.
|
||||
*
|
||||
* Null until BOTH exist: an unfinished assignment has no end, and a booking
|
||||
* whose arrival was never stamped has no start. Computed rather than stored
|
||||
* so a corrected timestamp cannot leave a stale number behind.
|
||||
*/
|
||||
timeAfterTrainArrives: number | null;
|
||||
/**
|
||||
* Whether documents may still be added or removed right now. Mirrors
|
||||
* `assertUploadAllowed` so the portal can disable its controls instead of
|
||||
* letting the agent discover the rule through a 403.
|
||||
*/
|
||||
canUploadDocuments: boolean;
|
||||
/**
|
||||
* Whose cargo this is. Flattened off the joined company so the portal grid
|
||||
* does not have to reach through `booking.company` — and so a booking with no
|
||||
* company (shipping-line bookings carry none) renders as a blank rather than
|
||||
* throwing.
|
||||
*/
|
||||
customerName: string | null;
|
||||
files?: TransitAssignmentFileView[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TransitAssignmentsService {
|
||||
constructor(
|
||||
private readonly assignmentsRepository: TransitAssignmentsRepository,
|
||||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||
// The Booking ENTITY, not BookingsModule: this only needs to confirm a
|
||||
// booking id exists, and importing that module would pull its whole graph
|
||||
// (billing, contracts, scheduling, first/last mile) in behind it.
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
private static minutesBetween(
|
||||
from?: Date | null,
|
||||
to?: Date | null,
|
||||
): number | null {
|
||||
if (!from || !to) return null;
|
||||
return Math.floor((to.getTime() - from.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
private toView(assignment: TransitAssignment): TransitAssignmentView {
|
||||
return {
|
||||
...assignment,
|
||||
timeAfterTrainArrives: TransitAssignmentsService.minutesBetween(
|
||||
assignment.booking?.arrivedAt,
|
||||
assignment.finishedAt,
|
||||
),
|
||||
canUploadDocuments:
|
||||
assignment.status !== TransitAssignmentStatus.Finished &&
|
||||
assignment.booking?.schedulingStatus === "DISPATCHED",
|
||||
customerName: assignment.booking?.company?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(query: TransitAssignmentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const [items, total] = await this.assignmentsRepository.findPaginated(
|
||||
{
|
||||
bookingId: query.bookingId,
|
||||
transitAgentId: query.transitAgentId,
|
||||
status: query.status,
|
||||
},
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.toView(item)),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Detail read — the only one that carries the attached documents. */
|
||||
async findById(id: string): Promise<TransitAssignmentView> {
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Every assignment handed to one transit agent — their workload list. */
|
||||
async findByTransitAgent(
|
||||
transitAgentId: string,
|
||||
): Promise<TransitAssignmentView[]> {
|
||||
const agent = await this.transitAgentsRepository.findById(transitAgentId);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${transitAgentId} not found`);
|
||||
}
|
||||
const rows =
|
||||
await this.assignmentsRepository.findByTransitAgent(transitAgentId);
|
||||
return rows.map((row) => this.toView(row));
|
||||
}
|
||||
|
||||
/** Every agent assigned to one booking. */
|
||||
async findByBooking(bookingId: string): Promise<TransitAssignmentView[]> {
|
||||
const rows = await this.assignmentsRepository.findByBooking(bookingId);
|
||||
return rows.map((row) => this.toView(row));
|
||||
}
|
||||
|
||||
// ── Portal (the signed-in transit agent's own work) ───────────────────────
|
||||
// Every one of these resolves the agent from the SESSION and never from a
|
||||
// client-supplied id: an agent must not be able to read or edit another
|
||||
// agent's assignments by guessing one.
|
||||
|
||||
/** The transit agent this portal user signs in as. */
|
||||
private async requireAgentForUser(userId: string) {
|
||||
const agent = await this.transitAgentsRepository.findByUserId(userId);
|
||||
if (!agent) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard figures for the signed-in agent's own work.
|
||||
*
|
||||
* Every interval is derived from timestamps that already exist — nothing is
|
||||
* stored, so a corrected arrival or finish time changes these on the next
|
||||
* read rather than leaving a stale metric behind.
|
||||
*
|
||||
* The median is used rather than the mean on purpose: one assignment
|
||||
* reopened days later drags an average far enough to make the whole panel
|
||||
* lie about typical performance.
|
||||
*/
|
||||
async myStats(userId: string) {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
|
||||
|
||||
const docCounts = rows.length
|
||||
? await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map<string, unknown[]>();
|
||||
|
||||
const minutes = (from?: Date | null, to?: Date | null) =>
|
||||
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null;
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const arrivedAt = row.booking?.arrivedAt ?? null;
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.booking?.reference ?? null,
|
||||
customerName: row.booking?.company?.name ?? null,
|
||||
status: row.status,
|
||||
schedulingStatus: row.booking?.schedulingStatus ?? null,
|
||||
/** Dispatch (cargo loaded) to the train arriving. */
|
||||
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt),
|
||||
/** Arrival to the agent picking the work up. */
|
||||
pickupMinutes: minutes(arrivedAt, row.startedAt),
|
||||
/** Arrival to the work being finished — the headline metric. */
|
||||
clearanceMinutes: minutes(arrivedAt, row.finishedAt),
|
||||
documentCount: (docCounts.get(row.id) ?? []).length,
|
||||
};
|
||||
});
|
||||
|
||||
const median = (values: number[]): number | null => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[mid]
|
||||
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
};
|
||||
|
||||
const cleared = items
|
||||
.map((i) => i.clearanceMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
const pickups = items
|
||||
.map((i) => i.pickupMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
|
||||
// SLA bands, in minutes: inside 2h, inside 6h, beyond.
|
||||
const sla = {
|
||||
under2h: cleared.filter((v) => v <= 120).length,
|
||||
under6h: cleared.filter((v) => v > 120 && v <= 360).length,
|
||||
over6h: cleared.filter((v) => v > 360).length,
|
||||
};
|
||||
|
||||
// Coverage counts only bookings that COULD have documents — uploads are
|
||||
// gated on dispatch, so counting scheduled ones would invent a failure.
|
||||
const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED");
|
||||
const withDocs = dispatched.filter((i) => i.documentCount > 0).length;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
assignments: items.length,
|
||||
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
|
||||
.length,
|
||||
finished: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
readyForDocuments: items.filter(
|
||||
(i) =>
|
||||
i.schedulingStatus === "DISPATCHED" &&
|
||||
i.status !== TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
documents: items.reduce((sum, i) => sum + i.documentCount, 0),
|
||||
},
|
||||
performance: {
|
||||
medianClearanceMinutes: median(cleared),
|
||||
medianPickupMinutes: median(pickups),
|
||||
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null,
|
||||
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null,
|
||||
onTimeRate: cleared.length
|
||||
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100)
|
||||
: null,
|
||||
measured: cleared.length,
|
||||
},
|
||||
sla,
|
||||
coverage: {
|
||||
dispatched: dispatched.length,
|
||||
withDocuments: withDocs,
|
||||
},
|
||||
/** Newest first, for the timeline and the recent-activity list. */
|
||||
items: items.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
async findMine(userId: string, query: MyAssignmentsQueryDto = {}) {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
|
||||
const [rows, total] =
|
||||
await this.assignmentsRepository.findByTransitAgentPaginated(
|
||||
agent.id,
|
||||
{
|
||||
status: query.status,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
search: query.search,
|
||||
},
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
// Documents come back with the list so the grid can show a per-row count.
|
||||
// Batched deliberately: one lookup for the page, not one per assignment.
|
||||
const grouped = rows.length
|
||||
? await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((row) => row.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map();
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...this.toView(row),
|
||||
files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
title: record.title,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
})),
|
||||
})),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the signed-in agent's own assignments, with its documents.
|
||||
* Ownership is asserted rather than filtered: a mismatch is hidden behind a
|
||||
* NotFound so assignment ids cannot be probed.
|
||||
*/
|
||||
async findMineById(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment || assignment.transitAgentId !== agent.id) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Assert the assignment is this user's before any write reaches it. */
|
||||
private async assertMine(userId: string, id: string): Promise<void> {
|
||||
await this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
async uploadMyFiles(
|
||||
userId: string,
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
uploader: { userId?: string; name?: string },
|
||||
titles?: string[],
|
||||
): Promise<TransitAssignmentFileView[]> {
|
||||
await this.assertMine(userId, id);
|
||||
return this.uploadFiles(id, files, uploader, titles);
|
||||
}
|
||||
|
||||
async removeMyFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
await this.assertMine(userId, id);
|
||||
return this.removeFile(id, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's Save / Finish action.
|
||||
*
|
||||
* Save keeps the assignment open (moving it to IN_PROGRESS so the work reads
|
||||
* as under way); Finish closes it, which also locks its documents — see
|
||||
* `assertUploadAllowed`.
|
||||
*/
|
||||
async submitMine(
|
||||
userId: string,
|
||||
id: string,
|
||||
input: { finish: boolean; note?: string },
|
||||
): Promise<TransitAssignmentView> {
|
||||
const current = await this.findMineById(userId, id);
|
||||
if (current.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException("This assignment is already finished.");
|
||||
}
|
||||
await this.update(id, {
|
||||
status: input.finish
|
||||
? TransitAssignmentStatus.Finished
|
||||
: TransitAssignmentStatus.InProgress,
|
||||
note: input.note,
|
||||
});
|
||||
return this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTransitAssignmentDto,
|
||||
assignedByUserId?: string,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: dto.bookingId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.findById(
|
||||
dto.transitAgentId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(
|
||||
`Transit agent ${dto.transitAgentId} not found`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
await this.assignmentsRepository.existsForPair(
|
||||
dto.bookingId,
|
||||
dto.transitAgentId,
|
||||
)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`${agent.name} is already assigned to this booking`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = dto.status ?? TransitAssignmentStatus.NotStarted;
|
||||
const created = await this.assignmentsRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
transitAgentId: dto.transitAgentId,
|
||||
status,
|
||||
// Creating straight into a working state still has to stamp its clock, or
|
||||
// the assignment would report no start.
|
||||
startedAt:
|
||||
status === TransitAssignmentStatus.NotStarted ? null : new Date(),
|
||||
finishedAt:
|
||||
status === TransitAssignmentStatus.Finished ? new Date() : null,
|
||||
assignedByUserId: assignedByUserId ?? null,
|
||||
note: dto.note?.trim() || null,
|
||||
});
|
||||
|
||||
return this.findById(created.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTransitAssignmentDto,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const current = await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
|
||||
const patch: Partial<TransitAssignment> = {};
|
||||
if (dto.note !== undefined) patch.note = dto.note.trim() || null;
|
||||
|
||||
if (dto.status && dto.status !== current.status) {
|
||||
patch.status = dto.status;
|
||||
if (dto.status === TransitAssignmentStatus.InProgress) {
|
||||
// Only the FIRST start is recorded — reopening finished work keeps the
|
||||
// original start, so the elapsed time still spans the whole job.
|
||||
patch.startedAt = current.startedAt ?? new Date();
|
||||
patch.finishedAt = null;
|
||||
} else if (dto.status === TransitAssignmentStatus.Finished) {
|
||||
patch.startedAt = current.startedAt ?? new Date();
|
||||
patch.finishedAt = new Date();
|
||||
} else {
|
||||
// Back to NOT_STARTED — the work is being reset, so both clocks clear
|
||||
// rather than leaving a duration for work that no longer happened.
|
||||
patch.startedAt = null;
|
||||
patch.finishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.assignmentsRepository.update(id, patch);
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.assignmentsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
// ── Documents ─────────────────────────────────────────────────────────────
|
||||
// Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather
|
||||
// than a table of their own: that one already carries the MinIO object, the
|
||||
// upload time, the uploader and the supersede history.
|
||||
|
||||
/**
|
||||
* Whether an assignment may still receive documents.
|
||||
*
|
||||
* Two gates, both business rules rather than UI conveniences:
|
||||
* - the booking must actually be on its way (`DISPATCHED`), since there is
|
||||
* nothing to clear before the train leaves;
|
||||
* - the assignment must not be FINISHED — filing closes with the work, so a
|
||||
* finished record cannot grow new paperwork afterwards.
|
||||
*/
|
||||
private assertUploadAllowed(assignment: TransitAssignment): void {
|
||||
if (assignment.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException(
|
||||
"This assignment is finished — its documents can no longer be changed.",
|
||||
);
|
||||
}
|
||||
if (assignment.booking?.schedulingStatus !== "DISPATCHED") {
|
||||
throw new ForbiddenException(
|
||||
"Documents can only be uploaded once the booking has been dispatched.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(id: string): Promise<TransitAssignmentFileView[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
id,
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
);
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
title: record.title,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFiles(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
uploader: { userId?: string; name?: string },
|
||||
/**
|
||||
* A display name per file, positionally matched to `files`. Multer preserves
|
||||
* the multipart part order, and the client appends one `titles` entry per
|
||||
* file in the same order, so index N names file N. A missing or blank entry
|
||||
* falls back to the original filename.
|
||||
*/
|
||||
titles?: string[],
|
||||
): Promise<TransitAssignmentFileView[]> {
|
||||
if (!files?.length) {
|
||||
throw new BadRequestException("No files were uploaded");
|
||||
}
|
||||
// Asserts the assignment exists before anything reaches MinIO — an upload
|
||||
// keyed to a missing row would be unreachable storage nobody ever lists.
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
this.assertUploadAllowed(assignment);
|
||||
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
this.filesService.upload({
|
||||
resourceId: id,
|
||||
resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
code: file.fieldname || "document",
|
||||
file,
|
||||
title: titles?.[index]?.trim() || null,
|
||||
uploadedByUserId: uploader.userId ?? null,
|
||||
uploadedByName: uploader.name ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return this.listFiles(id);
|
||||
}
|
||||
|
||||
async removeFile(id: string, fileId: string): Promise<void> {
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
// Same gate as upload: a finished assignment's paperwork is fixed, and
|
||||
// removal is as much a change as adding.
|
||||
this.assertUploadAllowed(assignment);
|
||||
|
||||
const files = await this.filesService.findByResource(
|
||||
id,
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
);
|
||||
// Scoped to this assignment's own documents: a bare file id would let one
|
||||
// assignment delete another's paperwork.
|
||||
if (!files.some((file) => file.id === fileId)) {
|
||||
throw new NotFoundException(
|
||||
`File ${fileId} not found on this assignment`,
|
||||
);
|
||||
}
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,32 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
// C3. Transit assignments — a transit agent's work on one booking: status,
|
||||
// timings and documents. Separate from the booking's transit-assignee handshake,
|
||||
// which only decides who will handle customs.
|
||||
export const TRANSIT_ASSIGNMENT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:transit_assignments:view",
|
||||
"View transit assignments",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:transit_assignments:create",
|
||||
"Assign a transit agent to a booking",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:transit_assignments:update",
|
||||
"Update a transit assignment and its documents",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:transit_assignments:delete",
|
||||
"Remove a transit assignment",
|
||||
),
|
||||
];
|
||||
|
||||
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
|
||||
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -1890,6 +1916,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...OVERVIEW_LAYOUT_PERMISSIONS,
|
||||
...CUSTOMER_PERMISSIONS,
|
||||
...SHIPPING_LINE_PERMISSIONS,
|
||||
...TRANSIT_ASSIGNMENT_PERMISSIONS,
|
||||
...CHAT_PERMISSIONS,
|
||||
...FINANCE_PERMISSIONS,
|
||||
...MILE_PERMISSIONS,
|
||||
@@ -2118,6 +2145,12 @@ export const FREIGHT_PERMS = {
|
||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:customers:get_notification",
|
||||
},
|
||||
transitAssignments: {
|
||||
view: "edr_freight_app:transit_assignments:view",
|
||||
create: "edr_freight_app:transit_assignments:create",
|
||||
update: "edr_freight_app:transit_assignments:update",
|
||||
delete: "edr_freight_app:transit_assignments:delete",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"react-phone-number-input": "^3.4.17",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^6.27.0",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSmsReachable, isValidPhone } from "./PhoneField";
|
||||
|
||||
/**
|
||||
* `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two
|
||||
* must agree: this one greys out the SMS option, that one decides whether the
|
||||
* message is actually sent, and a disagreement means the UI promises a text
|
||||
* nobody sends (or hides one that would have worked). These cases are the same
|
||||
* ones the API spec asserts.
|
||||
*/
|
||||
describe("isSmsReachable", () => {
|
||||
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||
"accepts Ethiopian mobile form %s",
|
||||
(phone) => expect(isSmsReachable(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+25377123456", "25377123456", "77123456"])(
|
||||
"accepts Djibouti mobile form %s",
|
||||
(phone) => expect(isSmsReachable(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"+14155550123",
|
||||
"+447911123456",
|
||||
"0712345678",
|
||||
"+2519866",
|
||||
"12345",
|
||||
// Djibouti fixed line — valid number, not a mobile the gateway serves.
|
||||
"+25321350000",
|
||||
"+25366123456",
|
||||
])("rejects unreachable or malformed %s", (phone) =>
|
||||
expect(isSmsReachable(phone)).toBe(false),
|
||||
);
|
||||
|
||||
it.each([undefined, null, ""])("treats %s as unreachable", (phone) =>
|
||||
expect(isSmsReachable(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The country-picker input emits a PARTIAL E.164 while the user is still
|
||||
* typing — "+25377" is a non-empty string that will post happily and come back
|
||||
* as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and
|
||||
* "complete" as different questions, so this is the check they call.
|
||||
*/
|
||||
describe("isValidPhone", () => {
|
||||
it.each(["+25377834567", "+251911223344"])(
|
||||
"accepts the complete number %s",
|
||||
(phone) => expect(isValidPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])(
|
||||
"rejects the partial number %s the picker emits mid-typing",
|
||||
(phone) => expect(isValidPhone(phone)).toBe(false),
|
||||
);
|
||||
|
||||
it.each([undefined, null, ""])("treats %s as invalid", (phone) =>
|
||||
expect(isValidPhone(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal file
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Input, TextInput } from "@mantine/core";
|
||||
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
import "./phone-field.css";
|
||||
|
||||
/**
|
||||
* The countries the railway operates between, and the only two the SMS gateway
|
||||
* is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's
|
||||
* otp.service). Restricting the picker to them keeps staff from entering a
|
||||
* number that would validate but could never receive an activation link.
|
||||
*/
|
||||
export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const;
|
||||
|
||||
/**
|
||||
* Djibouti — most accounts entered here (transit agents above all) are
|
||||
* Djibouti-side, so it saves the picker interaction on the common case.
|
||||
*/
|
||||
export const DEFAULT_PHONE_COUNTRY = "DJ";
|
||||
|
||||
/**
|
||||
* Re-exported so callers can validate before submitting.
|
||||
*
|
||||
* Needed because the input emits a PARTIAL E.164 while the user is still
|
||||
* typing — "+25377" and "+2537712" are non-empty strings that reach a payload
|
||||
* happily and then come back as a 400 from the API's own `IsValidPhone`. A
|
||||
* caller must treat "non-empty" and "complete" as different questions.
|
||||
*/
|
||||
export const isValidPhone = (value?: string | null): boolean =>
|
||||
!!value && isValidPhoneNumber(value);
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and
|
||||
* Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is
|
||||
* queued and silently lost, so the UI offers email instead of promising an SMS.
|
||||
*/
|
||||
export function isSmsReachable(rawPhone?: string | null): boolean {
|
||||
if (!rawPhone) return false;
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const bare = digits.replace(/^\+/, "").replace(/^0+/, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$|^253\d{8}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(bare)
|
||||
? `+251${bare}`
|
||||
: /^77\d{6}$/.test(bare)
|
||||
? `+253${bare}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized);
|
||||
}
|
||||
|
||||
export interface PhoneFieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone input with a country selector, limited to Ethiopia and Djibouti.
|
||||
* Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API
|
||||
* never has to guess a country from a bare local number.
|
||||
*/
|
||||
export function PhoneField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "77 83 45 67",
|
||||
description,
|
||||
}: PhoneFieldProps) {
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
required={required}
|
||||
error={error}
|
||||
description={description}
|
||||
styles={{ label: { fontWeight: 600, fontSize: 14, color: "#10202F" } }}
|
||||
>
|
||||
<div
|
||||
className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}
|
||||
>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry={DEFAULT_PHONE_COUNTRY}
|
||||
countries={[...SUPPORTED_PHONE_COUNTRIES]}
|
||||
countryCallingCodeEditable={false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inputComponent={TextInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneField;
|
||||
@@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
);
|
||||
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
// NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the
|
||||
// count the customer fixed is what allocation and per-wagon pricing use, so
|
||||
// it belongs on the card next to the weight.
|
||||
const requestedWagons =
|
||||
isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS"
|
||||
? Number(booking.bulkRequestedWagons ?? 0) || null
|
||||
: null;
|
||||
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
|
||||
// the freight kind, with the shipper's own description alongside.
|
||||
const cargoHeadline = isBulk
|
||||
@@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
{requestedWagons != null ? (
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
{requestedWagons} wagon{requestedWagons === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{cargoDescription ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
— {cargoDescription}
|
||||
@@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
}
|
||||
/>
|
||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||
{requestedWagons != null && (
|
||||
<MetricTile label="Wagons booked" value={`${requestedWagons}`} />
|
||||
)}
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Align react-phone-number-input with the portal's Mantine field styling:
|
||||
44px height, 10px radius, edr border, brand-green focus ring. */
|
||||
|
||||
.edr-phone-wrapper .PhoneInput {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Country selector — a compact pill matching the input height/radius. */
|
||||
.edr-phone-wrapper .PhoneInputCountry {
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
height: 2.25rem;
|
||||
border: 0.0625rem solid #b0bfce;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountryIcon {
|
||||
width: 22px;
|
||||
height: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
|
||||
color: #6b7c8e;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The number input itself. */
|
||||
.edr-phone-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e6ecf2;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #10202f;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.edr-phone-input::placeholder {
|
||||
color: #9aa8b5;
|
||||
}
|
||||
|
||||
.edr-phone-input:focus {
|
||||
border-color: #0ea371;
|
||||
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountry:focus-within {
|
||||
border-color: #0ea371;
|
||||
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
|
||||
}
|
||||
|
||||
.edr-phone-input:disabled,
|
||||
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error state mirrors Mantine's invalid styling. */
|
||||
.edr-phone-wrapper--error .edr-phone-input,
|
||||
.edr-phone-wrapper--error .PhoneInputCountry {
|
||||
border-color: #e03131;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper--error .edr-phone-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import PhoneField, { isValidPhone } from "@/components/PhoneField";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
|
||||
|
||||
@@ -46,7 +47,11 @@ type FormRow =
|
||||
/** One editable distance tier of a tierList field (raw input strings). */
|
||||
type TierRow = { minKm: string; maxKm: string; rateValue: string };
|
||||
|
||||
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" });
|
||||
const emptyTier = (fromKm = ""): TierRow => ({
|
||||
minKm: fromKm,
|
||||
maxKm: "",
|
||||
rateValue: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a tier set before submit: every tier complete, ranges sane, no
|
||||
@@ -87,7 +92,11 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
|
||||
while (index < fields.length) {
|
||||
const field = fields[index];
|
||||
|
||||
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
|
||||
if (
|
||||
field.type === "textarea" ||
|
||||
field.type === "boolean" ||
|
||||
field.type === "tierList"
|
||||
) {
|
||||
rows.push({ kind: "single", field });
|
||||
index += 1;
|
||||
continue;
|
||||
@@ -113,9 +122,10 @@ const buildInitialValues = (
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const raw = field.getInitialValue && record
|
||||
? field.getInitialValue(record)
|
||||
: record?.[field.name];
|
||||
const raw =
|
||||
field.getInitialValue && record
|
||||
? field.getInitialValue(record)
|
||||
: record?.[field.name];
|
||||
if (field.type === "multiselect") {
|
||||
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
|
||||
} else if (field.type === "tierList") {
|
||||
@@ -160,10 +170,13 @@ const resolveSelectValue = (
|
||||
};
|
||||
|
||||
const inputStyles = {
|
||||
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
|
||||
label: {
|
||||
fontWeight: 600,
|
||||
marginBottom: 6,
|
||||
color: "var(--mantine-color-gray-8)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
const RuleEngineFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -195,13 +208,17 @@ const RuleEngineFormDialog = ({
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
field.hideWhen.equals.includes(
|
||||
String(values[field.hideWhen.field] ?? ""),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
!field.showWhen.equals.includes(
|
||||
String(values[field.showWhen.field] ?? ""),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -228,7 +245,10 @@ const RuleEngineFormDialog = ({
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
// allowed set instead of submitting a stale, rejected unit.
|
||||
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
||||
if (
|
||||
(name === "appliesTo" || name === "trigger") &&
|
||||
"rateUnit" in current
|
||||
) {
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// The legal yards depend on what the rate is for and which way it runs, so
|
||||
@@ -342,6 +362,20 @@ const RuleEngineFormDialog = ({
|
||||
[field.name]: `${field.label} is required.`,
|
||||
}));
|
||||
blocked = true;
|
||||
} else if (field.type === "phone" && raw !== "" && raw !== undefined) {
|
||||
// The country-picker input emits a PARTIAL E.164 while the user is
|
||||
// still typing ("+25377"), which is non-empty and would post straight
|
||||
// through to a 400 from the API's own validator. Reject it here, on the
|
||||
// field, instead of as a server error the admin has to decode.
|
||||
if (!isValidPhone(String(raw))) {
|
||||
setFieldErrors((current) => ({
|
||||
...current,
|
||||
[field.name]: `${field.label} is not a complete phone number.`,
|
||||
}));
|
||||
blocked = true;
|
||||
} else {
|
||||
payload[field.name] = raw;
|
||||
}
|
||||
} else if (raw === "" || raw === undefined) {
|
||||
if (!field.required) continue;
|
||||
payload[field.name] = raw;
|
||||
@@ -350,13 +384,19 @@ const RuleEngineFormDialog = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) {
|
||||
if (
|
||||
fields.some((f) => f.name === "code" && typeof payload.code === "string")
|
||||
) {
|
||||
payload.code = String(payload.code).toUpperCase();
|
||||
}
|
||||
|
||||
if (blocked) return;
|
||||
|
||||
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||
if (
|
||||
!initialRecord &&
|
||||
positionOptions &&
|
||||
position !== RULE_ENGINE_POSITION_END
|
||||
) {
|
||||
payload.insertAfterId = position;
|
||||
}
|
||||
|
||||
@@ -421,7 +461,9 @@ const RuleEngineFormDialog = ({
|
||||
const setRows = (next: TierRow[]) => setField(field.name, next);
|
||||
const setRow = (index: number, key: keyof TierRow, value: string) => {
|
||||
if (value.trim().startsWith("-")) return;
|
||||
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
|
||||
setRows(
|
||||
rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Box key={field.name}>
|
||||
@@ -443,7 +485,9 @@ const RuleEngineFormDialog = ({
|
||||
step="any"
|
||||
placeholder="0"
|
||||
value={row.minKm}
|
||||
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
|
||||
onChange={(e) =>
|
||||
setRow(index, "minKm", e.currentTarget.value)
|
||||
}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
@@ -456,7 +500,9 @@ const RuleEngineFormDialog = ({
|
||||
step="any"
|
||||
placeholder="No limit"
|
||||
value={row.maxKm}
|
||||
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
|
||||
onChange={(e) =>
|
||||
setRow(index, "maxKm", e.currentTarget.value)
|
||||
}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
@@ -469,7 +515,9 @@ const RuleEngineFormDialog = ({
|
||||
step="any"
|
||||
placeholder="Rate per km"
|
||||
value={row.rateValue}
|
||||
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
|
||||
onChange={(e) =>
|
||||
setRow(index, "rateValue", e.currentTarget.value)
|
||||
}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
@@ -494,7 +542,12 @@ const RuleEngineFormDialog = ({
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
// The next tier naturally starts where the previous one ends.
|
||||
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
|
||||
onClick={() =>
|
||||
setRows([
|
||||
...rows,
|
||||
emptyTier(rows[rows.length - 1]?.maxKm ?? ""),
|
||||
])
|
||||
}
|
||||
>
|
||||
Add tier
|
||||
</Button>
|
||||
@@ -531,7 +584,10 @@ const RuleEngineFormDialog = ({
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE)
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE,
|
||||
)
|
||||
.map((opt) => ({ label: opt.label, value: opt.value }))}
|
||||
searchable
|
||||
clearable
|
||||
@@ -545,7 +601,9 @@ const RuleEngineFormDialog = ({
|
||||
if (field.type === "select") {
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
const options = field.optionsFromValues
|
||||
? field.optionsFromValues(values)
|
||||
: (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
@@ -558,14 +616,18 @@ const RuleEngineFormDialog = ({
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
selectOptionsLoading
|
||||
? "Loading options..."
|
||||
: (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
onChange={(v) =>
|
||||
setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)
|
||||
}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
@@ -635,8 +697,29 @@ const RuleEngineFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "phone") {
|
||||
// Country-picker input restricted to Ethiopia and Djibouti — the two the
|
||||
// SMS gateway reaches. Emits E.164, so the API never guesses a country
|
||||
// from a bare local number.
|
||||
return (
|
||||
<PhoneField
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(v) => setField(field.name, v ?? "")}
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
required={field.required}
|
||||
error={fieldErrors[field.name] || undefined}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isNumber = field.type === "number";
|
||||
const computed = field.computeValue ? field.computeValue(values) : undefined;
|
||||
const computed = field.computeValue
|
||||
? field.computeValue(values)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
@@ -654,8 +737,14 @@ const RuleEngineFormDialog = ({
|
||||
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
||||
// so let the field carry decimals and let a 400 catch the rest.
|
||||
step={isNumber ? "any" : undefined}
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
|
||||
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computed !== undefined
|
||||
}
|
||||
value={String(
|
||||
(computed !== undefined ? computed : values[field.name]) ?? "",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
if (isNumber && next.trim().startsWith("-")) return;
|
||||
@@ -703,16 +792,27 @@ const RuleEngineFormDialog = ({
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="lg">
|
||||
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||
<Box
|
||||
style={{
|
||||
maxHeight: "calc(65vh - 120px)",
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{!initialRecord && positionOptions ? (
|
||||
<Select
|
||||
label="Position"
|
||||
description="New items are appended to the end by default."
|
||||
value={position}
|
||||
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
|
||||
onChange={(value) =>
|
||||
setPosition(value ?? RULE_ENGINE_POSITION_END)
|
||||
}
|
||||
data={[
|
||||
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
|
||||
{
|
||||
label: "At end (default)",
|
||||
value: RULE_ENGINE_POSITION_END,
|
||||
},
|
||||
...positionOptions,
|
||||
]}
|
||||
searchable
|
||||
@@ -724,9 +824,17 @@ const RuleEngineFormDialog = ({
|
||||
) : null}
|
||||
{formRows.map((row) =>
|
||||
row.kind === "pair" ? (
|
||||
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
|
||||
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
|
||||
<SimpleGrid
|
||||
key={`${row.fields[0].name}-${row.fields[1].name}`}
|
||||
cols={2}
|
||||
spacing="md"
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
{renderField(row.fields[0])}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
{renderField(row.fields[1])}
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Box key={row.field.name}>{renderField(row.field)}</Box>
|
||||
@@ -752,7 +860,10 @@ const RuleEngineFormDialog = ({
|
||||
disabled={isSubmitting}
|
||||
leftSection={
|
||||
isSubmitting ? (
|
||||
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
|
||||
<Loader2
|
||||
size={18}
|
||||
style={{ animation: "spin 1s linear infinite" }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
radius="md"
|
||||
|
||||
@@ -25,12 +25,20 @@ export const formatCell = (
|
||||
row?: Record<string, unknown>,
|
||||
): ReactNode => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <Text size="sm" c="dimmed">—</Text>;
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle stringified objects (e.g., "[object Object]")
|
||||
if (typeof value === "string" && value.trim() === "[object Object]") {
|
||||
return <Text size="sm" c="dimmed">—</Text>;
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "boolean") {
|
||||
@@ -75,9 +83,17 @@ export const formatCell = (
|
||||
if (format === "validityBadge") {
|
||||
const status = String(value);
|
||||
const label =
|
||||
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
|
||||
status === "VALID"
|
||||
? "Valid"
|
||||
: status === "EXPIRED"
|
||||
? "Expired"
|
||||
: "Not started";
|
||||
const color =
|
||||
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
|
||||
status === "VALID"
|
||||
? "edr-green"
|
||||
: status === "EXPIRED"
|
||||
? "red"
|
||||
: "yellow";
|
||||
return (
|
||||
<Badge color={color} variant="filled" size="sm" radius="md">
|
||||
{label}
|
||||
@@ -85,6 +101,20 @@ export const formatCell = (
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "accountBadge") {
|
||||
// `hasAccount` — whether a portal login backs this row. Roster-only rows
|
||||
// predate accounts and stay legal, so "no" is a neutral dash, not a warning.
|
||||
return value ? (
|
||||
<Badge color="edr-green" variant="filled" size="sm" radius="md">
|
||||
Invited
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "code") {
|
||||
return (
|
||||
<Badge
|
||||
@@ -110,7 +140,11 @@ export const formatCell = (
|
||||
|
||||
if (format === "number") {
|
||||
const num = Number(value);
|
||||
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
|
||||
return (
|
||||
<Text size="sm">
|
||||
{Number.isNaN(num) ? String(value) : num.toLocaleString()}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "currency") {
|
||||
@@ -125,14 +159,13 @@ export const formatCell = (
|
||||
|
||||
if (format === "date") {
|
||||
const d = new Date(String(value));
|
||||
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
|
||||
if (Number.isNaN(d.getTime()))
|
||||
return <Text size="sm">{String(value)}</Text>;
|
||||
return <Text size="sm">{d.toLocaleDateString()}</Text>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
|
||||
);
|
||||
return <Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>;
|
||||
}
|
||||
|
||||
if (format === "entityLabel" && value && typeof value === "object") {
|
||||
@@ -140,7 +173,11 @@ export const formatCell = (
|
||||
if (label) {
|
||||
return <Text size="sm">{label}</Text>;
|
||||
}
|
||||
return <Text size="sm" c="dimmed">—</Text>;
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "rateLabel") {
|
||||
@@ -150,7 +187,9 @@ export const formatCell = (
|
||||
{String(value)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const rate = value as {
|
||||
@@ -168,14 +207,20 @@ export const formatCell = (
|
||||
return parts.length > 0 ? (
|
||||
<Text size="sm">{parts.join(" · ")}</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const label = extractLabel(value);
|
||||
if (label) return <Text size="sm">{label}</Text>;
|
||||
return <Text size="sm" c="dimmed">—</Text>;
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text size="sm">{String(value)}</Text>;
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { isSmsReachable } from "@/components/PhoneField";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
@@ -21,27 +22,19 @@ import type {
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* The carrier integration is domestic-only: anything else is queued and
|
||||
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||
*/
|
||||
function isDomesticPhone(rawPhone: string): boolean {
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||
? `+251${digits.replace(/^0+/, "")}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized);
|
||||
/** The minimum an account needs for the link dialog to describe its channels. */
|
||||
export interface ActivationTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface ResendActivationActionProps {
|
||||
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||
shippingLine: Pick<
|
||||
ShippingLineCompany,
|
||||
"id" | "name" | "email" | "phoneNumber"
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,15 +49,13 @@ export default function ResendActivationAction({
|
||||
}: ResendActivationActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||
|
||||
const allowed = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLines.resetPassword,
|
||||
);
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
@@ -82,42 +73,100 @@ export default function ResendActivationAction({
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
const phoneUsable =
|
||||
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||
return (
|
||||
<ActivationLinkDialog
|
||||
target={shippingLine}
|
||||
audienceLabel="shipping line"
|
||||
isPending={isPending}
|
||||
onSend={(channel, onDone) =>
|
||||
mutate({ id: shippingLine.id, channel }, { onSuccess: onDone })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActivationLinkDialogProps {
|
||||
target: ActivationTarget;
|
||||
/** How the account is described in the dialog copy ("shipping line", "transit agent"). */
|
||||
audienceLabel: string;
|
||||
isPending: boolean;
|
||||
/**
|
||||
* Perform the send. `onDone` closes the dialog — the caller owns the mutation
|
||||
* (and its toast) because each audience posts to its own endpoint.
|
||||
*/
|
||||
onSend: (channel: ResetChannel, onDone: () => void) => void;
|
||||
/** Overrides the icon-button trigger, e.g. a labelled "Invite" button. */
|
||||
trigger?: (open: () => void) => ReactNode;
|
||||
title?: string;
|
||||
submitLabel?: string;
|
||||
/** Extra fields above the channel picker — the invite flow collects the address here. */
|
||||
children?: ReactNode;
|
||||
/** Blocks the send button, e.g. while a required address is still empty. */
|
||||
submitDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel picker behind every activation-link send.
|
||||
*
|
||||
* Extracted from the shipping-line action so transit agents get the identical
|
||||
* dialog — including the domestic-only SMS rule, which is the part most likely
|
||||
* to be re-implemented subtly wrong.
|
||||
*/
|
||||
export function ActivationLinkDialog({
|
||||
target,
|
||||
audienceLabel,
|
||||
isPending,
|
||||
onSend,
|
||||
trigger,
|
||||
title = "Resend activation link",
|
||||
submitLabel = "Send activation link",
|
||||
children,
|
||||
submitDisabled,
|
||||
}: ActivationLinkDialogProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const phoneUsable = isSmsReachable(target.phoneNumber);
|
||||
const channelMissing = channel === "phone" && !phoneUsable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{trigger ? (
|
||||
trigger(() => setOpened(true))
|
||||
) : (
|
||||
<Tooltip label={title} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`${title} to ${target.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Resend activation link"
|
||||
title={title}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to {shippingLine.name}. They choose
|
||||
their own password — you will not see it. The link expires in 24
|
||||
hours, and sending a new one invalidates nothing they haven't
|
||||
already used.
|
||||
We'll send a single-use link to this {audienceLabel},{" "}
|
||||
{target.name}. They choose their own password — you will not see it.
|
||||
The link expires in 24 hours, and sending a new one invalidates
|
||||
nothing they haven't already used.
|
||||
</Text>
|
||||
|
||||
{children}
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
@@ -127,18 +176,19 @@ export default function ResendActivationAction({
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={shippingLine.email}
|
||||
disabled={!target.email}
|
||||
description={target.email ?? "No email on this account"}
|
||||
/>
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!shippingLine.phoneNumber
|
||||
!target.phoneNumber
|
||||
? "No phone number on this account"
|
||||
: !phoneUsable
|
||||
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||
: shippingLine.phoneNumber
|
||||
? `${target.phoneNumber} — the SMS gateway does not reach this number; use email`
|
||||
: target.phoneNumber
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -156,10 +206,10 @@ export default function ResendActivationAction({
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||
disabled={channelMissing || submitDisabled}
|
||||
onClick={() => onSend(channel, () => setOpened(false))}
|
||||
>
|
||||
Send activation link
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -303,6 +303,7 @@ export function ScheduleWorkspacePanel({
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
// Per-wagon loading/unloading modal for one booking.
|
||||
const [wagonModal, setWagonModal] = useState<{
|
||||
isExport: boolean;
|
||||
bookingId: string;
|
||||
ref: string;
|
||||
phase: "load" | "unload";
|
||||
@@ -908,7 +909,12 @@ export function ScheduleWorkspacePanel({
|
||||
radius="md"
|
||||
disabled={!canLoad || !loadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "load" })
|
||||
setWagonModal({
|
||||
bookingId: b.id,
|
||||
ref,
|
||||
phase: "load",
|
||||
isExport: b.tradeDirection === "EXPORT",
|
||||
})
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
@@ -987,7 +993,12 @@ export function ScheduleWorkspacePanel({
|
||||
radius="md"
|
||||
disabled={!canUnload || !unloadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
|
||||
setWagonModal({
|
||||
bookingId: b.id,
|
||||
ref,
|
||||
phase: "unload",
|
||||
isExport: b.tradeDirection === "EXPORT",
|
||||
})
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
@@ -1035,6 +1046,7 @@ export function ScheduleWorkspacePanel({
|
||||
bookingId={wagonModal.bookingId}
|
||||
reference={wagonModal.ref}
|
||||
phase={wagonModal.phase}
|
||||
isExport={wagonModal.isExport}
|
||||
onClose={() => setWagonModal(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
@@ -1423,6 +1435,7 @@ function PerWagonModal({
|
||||
bookingId,
|
||||
reference,
|
||||
phase,
|
||||
isExport,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
@@ -1430,13 +1443,35 @@ function PerWagonModal({
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
phase: "load" | "unload";
|
||||
/**
|
||||
* EXPORT booking — only these may use the truck-to-train submit, which is
|
||||
* what the server's handover-mode endpoint enforces too (it 400s on any
|
||||
* other direction).
|
||||
*/
|
||||
isExport: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
|
||||
const [reason, setReason] = useState("");
|
||||
const [edrFault, setEdrFault] = useState(false);
|
||||
// Wagons ticked for this submit. Load is a batch action now: pick the wagons
|
||||
// that physically went on, then submit once.
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
// Which submit is in flight — also decides whether the handover mode is set
|
||||
// first ("truck") or the GRN gate is left to reject ("load").
|
||||
const [submitting, setSubmitting] = useState<null | "load" | "truck">(null);
|
||||
// Submit awaiting confirmation. Loading is irreversible from this screen —
|
||||
// there is no "unload back to the yard" here — and truck-to-train also drops
|
||||
// the booking's GRN requirement for good, so both go through a confirm step.
|
||||
// The one open panel, if any. A single slot rather than a flag per panel:
|
||||
// separate booleans let the cancel form and a submit confirm show at the same
|
||||
// time, each with its own buttons.
|
||||
const [confirmSubmit, setConfirmSubmit] = useState<null | "load" | "truck" | "cancel">(
|
||||
null,
|
||||
);
|
||||
const cancelOpen = confirmSubmit === "cancel";
|
||||
|
||||
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
|
||||
input: { bookingId },
|
||||
@@ -1463,41 +1498,118 @@ function PerWagonModal({
|
||||
? ((err.response?.data as { message?: string })?.message ?? err.message)
|
||||
: String(err);
|
||||
|
||||
const onWagon = (allocationId: string) => {
|
||||
act
|
||||
.mutateAsync({ scheduleId, bookingId, allocationId })
|
||||
.then((r) => {
|
||||
void wagonsQuery.refetch();
|
||||
if (r.completed) {
|
||||
toast({
|
||||
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
|
||||
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
|
||||
const toggle = (allocationId: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(allocationId)) next.delete(allocationId);
|
||||
else next.add(allocationId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const pickedPending = pending.filter((w) => picked.has(w.allocationId));
|
||||
|
||||
/**
|
||||
* Submit the ticked wagons. There is no batch endpoint, so they go one at a
|
||||
* time in order — the server flips the booking to IN_TRANSIT on whichever
|
||||
* call clears the last unloaded wagon, so sequential is required, not just
|
||||
* convenient. The first failure stops the run: the wagons already sent stay
|
||||
* loaded (each call is its own transaction) and the toast names the survivor
|
||||
* count, so a retry only resends what is left.
|
||||
*
|
||||
* `mode: "truck"` first sets DIRECT_TO_TRAIN, which is what makes the GRN
|
||||
* gate let this booking through — see assertExportReceivedWithGrn. Plain
|
||||
* "load" sends nothing extra and lets that gate reject unreceived cargo.
|
||||
*/
|
||||
const onSubmit = (mode: "load" | "truck") => {
|
||||
const targets = pickedPending;
|
||||
if (!targets.length) return;
|
||||
setConfirmSubmit(null);
|
||||
setSubmitting(mode);
|
||||
|
||||
const run = async () => {
|
||||
if (mode === "truck") {
|
||||
await bookingsService.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN");
|
||||
}
|
||||
let done = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
for (const w of targets) {
|
||||
const r = await act.mutateAsync({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
allocationId: w.allocationId,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
} else {
|
||||
done += 1;
|
||||
if (r.completed) completed = true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Partial success is a real outcome here, not a rollback candidate:
|
||||
// report what landed so the operator knows what to retry.
|
||||
if (done > 0) {
|
||||
void wagonsQuery.refetch();
|
||||
onChanged();
|
||||
}
|
||||
throw Object.assign(err as Error, { partial: done });
|
||||
}
|
||||
return { done, completed };
|
||||
};
|
||||
|
||||
run()
|
||||
.then(({ done, completed }) => {
|
||||
void wagonsQuery.refetch();
|
||||
onChanged();
|
||||
setPicked(new Set());
|
||||
if (completed) {
|
||||
toast({
|
||||
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
|
||||
description:
|
||||
phase === "load"
|
||||
? `${reference}: every wagon is loaded — the booking is in transit${mode === "truck" ? " (direct truck-to-train handover)" : ""}.`
|
||||
: `${reference}: every wagon is unloaded — the booking arrived.`,
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
toast({
|
||||
title: phase === "load" ? "Wagons loaded" : "Wagons unloaded",
|
||||
description: `${reference}: ${done} wagon${done === 1 ? "" : "s"} ${phase === "load" ? "loaded" : "unloaded"}.`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
.catch((err) => {
|
||||
const sent = (err as { partial?: number }).partial ?? 0;
|
||||
toast({
|
||||
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
|
||||
description: errText(err),
|
||||
description: sent
|
||||
? `${sent} wagon(s) went through before this: ${errText(err)}`
|
||||
: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
})
|
||||
.finally(() => setSubmitting(null));
|
||||
};
|
||||
|
||||
const onCancelRemaining = () => {
|
||||
// Cut exactly the ticked wagons. Sending the ids (rather than omitting them
|
||||
// and letting the server cut the whole remainder) is what makes a partial
|
||||
// cancel possible while other wagons are still waiting to load.
|
||||
const ids = pickedPending.map((w) => w.allocationId);
|
||||
const count = ids.length;
|
||||
cancelRemaining
|
||||
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
|
||||
.mutateAsync({
|
||||
bookingId,
|
||||
scheduleId,
|
||||
reason: reason.trim(),
|
||||
edrFault,
|
||||
wagonAllocationIds: ids,
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Remaining wagons cancelled",
|
||||
title: "Wagons cancelled",
|
||||
description: edrFault
|
||||
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
|
||||
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
|
||||
? `${reference}: ${count} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
|
||||
: `${reference}: ${count} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
|
||||
});
|
||||
setPicked(new Set());
|
||||
onChanged();
|
||||
onClose();
|
||||
})
|
||||
@@ -1550,6 +1662,15 @@ function PerWagonModal({
|
||||
<Paper key={w.allocationId} withBorder radius="md" p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{!isDone(w) ? (
|
||||
<Checkbox
|
||||
checked={picked.has(w.allocationId)}
|
||||
onChange={() => toggle(w.allocationId)}
|
||||
disabled={submitting != null}
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
aria-label={`Select wagon ${w.sequenceNo ?? ""} to ${phase}`}
|
||||
/>
|
||||
) : null}
|
||||
<Badge size="sm" radius="sm" variant="outline" color="gray">
|
||||
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
|
||||
</Badge>
|
||||
@@ -1574,50 +1695,216 @@ function PerWagonModal({
|
||||
>
|
||||
{phase === "load" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
|
||||
}
|
||||
loading={
|
||||
act.isPending && act.variables?.allocationId === w.allocationId
|
||||
}
|
||||
onClick={() => onWagon(w.allocationId)}
|
||||
>
|
||||
{phase === "load" ? "Load" : "Unload"}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
!cancelOpen ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}…
|
||||
</Button>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
{pending.length > 0 && !confirmSubmit ? (
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
disabled={submitting != null}
|
||||
onClick={() =>
|
||||
setPicked(
|
||||
picked.size === pending.length
|
||||
? new Set()
|
||||
: new Set(pending.map((w) => w.allocationId)),
|
||||
)
|
||||
}
|
||||
>
|
||||
{picked.size === pending.length ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{pickedPending.length} of {pending.length} selected
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{phase === "load" ? (
|
||||
<Tooltip
|
||||
label="Cancel the selected wagons — they will not ride. Customer fault invoices the cancellation fee; EDR fault charges nothing."
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
disabled={!pickedPending.length || submitting != null}
|
||||
onClick={() => setConfirmSubmit("cancel")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{phase === "load" && isExport ? (
|
||||
<Tooltip
|
||||
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover, then loads the selected wagons."
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!pickedPending.length || submitting != null}
|
||||
loading={submitting === "truck"}
|
||||
onClick={() => setConfirmSubmit("truck")}
|
||||
>
|
||||
Truck to train
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
label={
|
||||
phase === "load"
|
||||
? "Load the selected wagons — export cargo must already be received at the warehouse with a GRN."
|
||||
: "Unload the selected wagons."
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
phase === "load" ? <PackageCheck size={14} /> : <PackageOpen size={14} />
|
||||
}
|
||||
disabled={!pickedPending.length || submitting != null}
|
||||
loading={submitting === "load"}
|
||||
onClick={() => setConfirmSubmit("load")}
|
||||
>
|
||||
{phase === "load" ? "Load" : "Unload"} {pickedPending.length || ""}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{confirmSubmit && confirmSubmit !== "cancel" ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={
|
||||
confirmSubmit === "truck"
|
||||
? "blue"
|
||||
: phase === "load"
|
||||
? "edr-green"
|
||||
: "orange"
|
||||
}
|
||||
>
|
||||
{confirmSubmit === "truck" ? (
|
||||
<Truck size={21} />
|
||||
) : phase === "load" ? (
|
||||
<PackageCheck size={21} />
|
||||
) : (
|
||||
<PackageOpen size={21} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={800}>
|
||||
{confirmSubmit === "truck"
|
||||
? "Load as direct truck-to-train?"
|
||||
: phase === "load"
|
||||
? `Load ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`
|
||||
: `Unload ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{reference}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{confirmSubmit === "truck"
|
||||
? `Sets direct truck-to-train handover for the whole booking (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document), then loads the ${pickedPending.length} selected wagon${pickedPending.length === 1 ? "" : "s"}.`
|
||||
: phase === "load"
|
||||
? "Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN."
|
||||
: "Stamps the selected wagons as unloaded and frees them for reuse."}
|
||||
</Text>
|
||||
{confirmSubmit === "truck" ? (
|
||||
<Group
|
||||
gap={8}
|
||||
p="xs"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-yellow-0)",
|
||||
border: "1px solid var(--mantine-color-yellow-3)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} color="#B54708" />
|
||||
<Text size="xs" c="yellow.9" fw={500}>
|
||||
The handover mode applies to the whole booking and stays set
|
||||
even if a wagon then fails to load — its GRN requirement is
|
||||
dropped for good.
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{pickedPending.length < pending.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{pending.length - pickedPending.length} wagon
|
||||
{pending.length - pickedPending.length === 1 ? "" : "s"} left
|
||||
un{phase === "load" ? "loaded" : "unloaded"} — the train cannot
|
||||
dispatch until they are {phase === "load" ? "loaded" : "unloaded"} or
|
||||
cancelled.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
confirmSubmit === "truck"
|
||||
? "blue"
|
||||
: phase === "load"
|
||||
? "edr-green"
|
||||
: "orange"
|
||||
}
|
||||
radius="md"
|
||||
leftSection={
|
||||
confirmSubmit === "truck" ? (
|
||||
<Truck size={14} />
|
||||
) : phase === "load" ? (
|
||||
<PackageCheck size={14} />
|
||||
) : (
|
||||
<PackageOpen size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => onSubmit(confirmSubmit === "truck" ? "truck" : "load")}
|
||||
>
|
||||
{confirmSubmit === "truck"
|
||||
? "Load direct"
|
||||
: phase === "load"
|
||||
? "Load"
|
||||
: "Unload"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{phase === "load" && cancelOpen && pickedPending.length > 0 ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Cancel {pending.length} unloaded wagon
|
||||
{pending.length === 1 ? "" : "s"} of {reference}
|
||||
Cancel {pickedPending.length} selected wagon
|
||||
{pickedPending.length === 1 ? "" : "s"} of {reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
The booking shrinks to its loaded wagons and the freed freight
|
||||
These wagons are cut from the booking and the freed freight
|
||||
becomes a rebookable credit. Customer fault: the cancellation
|
||||
fee is invoiced, payable afterwards. EDR fault: no fee.
|
||||
{pending.length > pickedPending.length
|
||||
? ` The other ${pending.length - pickedPending.length} unloaded wagon(s) stay on the booking and still have to be loaded or cancelled before dispatch.`
|
||||
: ""}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
@@ -1633,7 +1920,7 @@ function PerWagonModal({
|
||||
onChange={(e) => setEdrFault(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
|
||||
<Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1647,8 +1934,7 @@ function PerWagonModal({
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
</Paper>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Button, Stack, TextInput, Tooltip } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import PhoneField, { isValidPhone } from "@/components/PhoneField";
|
||||
import { ActivationLinkDialog } from "@/components/shipping-lines/ResendActivationAction";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { transitAgentsService } from "@/services/transit-agents.service";
|
||||
import type { TransitAgent } from "@/services/transit-agents.service";
|
||||
import type { ResetChannel } from "@/types/shippingLineCompany";
|
||||
|
||||
export interface TransitAgentAccountActionProps {
|
||||
agent: TransitAgent;
|
||||
/** Hidden entirely without the update permission, matching the other row controls. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account column's row control: **Invite** for an agent that has no portal
|
||||
* login yet, **Resend** for one that has.
|
||||
*
|
||||
* Inviting is deliberately its own action rather than a side effect of editing
|
||||
* the email: it mints an IAM user, and a field edit must never do that
|
||||
* implicitly — the roster rows that predate portal logins would start growing
|
||||
* accounts the first time anyone corrected a typo.
|
||||
*/
|
||||
export default function TransitAgentAccountAction({
|
||||
agent,
|
||||
disabled,
|
||||
}: TransitAgentAccountActionProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [email, setEmail] = useState(agent.email ?? "");
|
||||
const [phoneNumber, setPhoneNumber] = useState(agent.phoneNumber ?? "");
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
|
||||
|
||||
const invite = useMutation({
|
||||
mutationFn: () =>
|
||||
transitAgentsService.invite(agent.id, {
|
||||
email: email.trim(),
|
||||
phoneNumber: phoneNumber.trim() || undefined,
|
||||
}),
|
||||
onSuccess: async (result) => {
|
||||
await invalidate();
|
||||
toast({
|
||||
title: "Portal account created",
|
||||
description: result.activationSentTo
|
||||
? `${agent.name} can set their password using the link sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||
: `${agent.name} now has a portal account, but the activation link could not be sent — resend it.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({
|
||||
title: "Could not create the portal account",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: (channel: ResetChannel) =>
|
||||
transitAgentsService.resendActivation(agent.id, channel),
|
||||
onSuccess: (result) =>
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `${agent.name} can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
}),
|
||||
onError: (error: Error) =>
|
||||
toast({
|
||||
title: "Could not send activation link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
if (disabled) return null;
|
||||
|
||||
// Already has a login — the only thing left is another copy of the link, so
|
||||
// this is exactly the shipping-line dialog with a different endpoint behind it.
|
||||
if (agent.hasAccount) {
|
||||
return (
|
||||
<ActivationLinkDialog
|
||||
target={agent}
|
||||
audienceLabel="transit agent"
|
||||
isPending={resend.isPending}
|
||||
onSend={(channel, onDone) =>
|
||||
resend.mutate(channel, { onSuccess: onDone })
|
||||
}
|
||||
trigger={(open) => (
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<Button size="compact-xs" variant="light" onClick={open}>
|
||||
Resend
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivationLinkDialog
|
||||
// Show the addresses being typed, not the (empty) stored ones, so the
|
||||
// channel picker enables SMS as soon as a domestic number is entered.
|
||||
target={{
|
||||
...agent,
|
||||
email: email.trim() || null,
|
||||
phoneNumber: phoneNumber.trim() || null,
|
||||
}}
|
||||
audienceLabel="transit agent"
|
||||
isPending={invite.isPending}
|
||||
title="Create portal account"
|
||||
submitLabel="Create account and send link"
|
||||
// The channel choice is the dialog's, but invite always emails (and texts
|
||||
// a domestic number) — the API picks both. Sending is what matters here.
|
||||
// A half-typed number is a non-empty partial E.164 the API rejects with a
|
||||
// 400 — block it here rather than posting it.
|
||||
submitDisabled={
|
||||
!email.trim() || (!!phoneNumber.trim() && !isValidPhone(phoneNumber))
|
||||
}
|
||||
onSend={(_channel, onDone) =>
|
||||
invite.mutate(undefined, { onSuccess: onDone })
|
||||
}
|
||||
trigger={(open) => (
|
||||
<Tooltip label="Give this agent a portal login" withArrow>
|
||||
<Button size="compact-xs" variant="light" onClick={open}>
|
||||
Invite
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="a.bourhan@transit.dj"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<PhoneField
|
||||
label="Phone number"
|
||||
value={phoneNumber}
|
||||
onChange={(v) => setPhoneNumber(v ?? "")}
|
||||
error={
|
||||
phoneNumber.trim() && !isValidPhone(phoneNumber)
|
||||
? "Enter the complete number"
|
||||
: undefined
|
||||
}
|
||||
description="Ethiopian and Djiboutian mobiles also receive the link by SMS"
|
||||
/>
|
||||
</Stack>
|
||||
</ActivationLinkDialog>
|
||||
);
|
||||
}
|
||||
@@ -615,6 +615,10 @@ export const URL_CONSTANTS = {
|
||||
TRANSIT_AGENTS: "/transit-agents",
|
||||
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
|
||||
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
|
||||
/** Give an existing roster-only agent a portal account and send the link. */
|
||||
TRANSIT_AGENT_INVITE: (id: string) => `/transit-agents/${id}/invite`,
|
||||
TRANSIT_AGENT_RESEND_ACTIVATION: (id: string) =>
|
||||
`/transit-agents/${id}/resend-activation`,
|
||||
},
|
||||
RATE_MATRIX: {
|
||||
BASE: "/api/rate-matrices",
|
||||
|
||||
@@ -26,6 +26,8 @@ import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import TransitAgentAccountAction from "@/components/transit-agents/TransitAgentAccountAction";
|
||||
import type { TransitAgent } from "@/services/transit-agents.service";
|
||||
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
@@ -577,8 +579,9 @@ const RuleEngineResourcePage = () => {
|
||||
base.push({
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: config.orderConfig ? 200 : 140,
|
||||
minSize: config.orderConfig ? 180 : 120,
|
||||
// Transit agents carry an extra Invite/Resend button in this cell.
|
||||
size: config.orderConfig ? 200 : config.slug === "transit-agents" ? 200 : 140,
|
||||
minSize: config.orderConfig ? 180 : config.slug === "transit-agents" ? 180 : 120,
|
||||
meta: {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
@@ -597,6 +600,12 @@ const RuleEngineResourcePage = () => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{config.slug === "transit-agents" ? (
|
||||
<TransitAgentAccountAction
|
||||
agent={row.original as unknown as TransitAgent}
|
||||
disabled={!canUpdateControls}
|
||||
/>
|
||||
) : null}
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
|
||||
@@ -11,13 +11,14 @@ export type ColumnFormat =
|
||||
| "activeBadge"
|
||||
| "rateStatus"
|
||||
| "validityBadge"
|
||||
| "accountBadge"
|
||||
| "date"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "entityLabel"
|
||||
| "rateLabel";
|
||||
|
||||
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
|
||||
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "phone" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
|
||||
|
||||
export interface ResourceColumn {
|
||||
id: string;
|
||||
@@ -669,6 +670,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
accessorKey: "validityStatus",
|
||||
format: "validityBadge",
|
||||
},
|
||||
{
|
||||
id: "hasAccount",
|
||||
header: "Portal account",
|
||||
accessorKey: "hasAccount",
|
||||
format: "accountBadge",
|
||||
},
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
@@ -681,6 +688,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
required: true,
|
||||
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
optional: true,
|
||||
description:
|
||||
"Filling this on create makes the portal account and emails the activation link. On an existing agent, use the Invite button instead — editing here only corrects the address.",
|
||||
},
|
||||
{
|
||||
name: "phoneNumber",
|
||||
label: "Phone number",
|
||||
type: "phone",
|
||||
optional: true,
|
||||
description: "Ethiopian and Djiboutian mobiles also receive the link by SMS",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -993,13 +993,25 @@ export const api = {
|
||||
),
|
||||
|
||||
cancelRemainingWagons: endpoint<
|
||||
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
|
||||
{
|
||||
bookingId: string;
|
||||
scheduleId: string;
|
||||
reason: string;
|
||||
edrFault?: boolean;
|
||||
/** Cut only these never-loaded wagons; omit for the whole remainder. */
|
||||
wagonAllocationIds?: string[];
|
||||
},
|
||||
unknown
|
||||
>(
|
||||
"train-scheduling",
|
||||
"cancel-remaining-wagons",
|
||||
({ bookingId, scheduleId, reason, edrFault }) =>
|
||||
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
|
||||
({ bookingId, scheduleId, reason, edrFault, wagonAllocationIds }) =>
|
||||
trainSchedulingService.cancelRemainingWagons(bookingId, {
|
||||
scheduleId,
|
||||
reason,
|
||||
edrFault,
|
||||
wagonAllocationIds,
|
||||
}),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
@@ -546,7 +546,12 @@ export const trainSchedulingService = {
|
||||
|
||||
cancelRemainingWagons: async (
|
||||
bookingId: string,
|
||||
payload: { scheduleId: string; reason: string; edrFault?: boolean },
|
||||
payload: {
|
||||
scheduleId: string;
|
||||
reason: string;
|
||||
edrFault?: boolean;
|
||||
wagonAllocationIds?: string[];
|
||||
},
|
||||
): Promise<unknown> => {
|
||||
const response = await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "../auth/http";
|
||||
import { api as apiClient } from "../auth/http";
|
||||
import { URL_CONSTANTS } from "../constants/URLS";
|
||||
import type { ResetChannel } from "../types/shippingLineCompany";
|
||||
|
||||
export interface TransitAgent {
|
||||
id: string;
|
||||
@@ -7,14 +8,53 @@ export interface TransitAgent {
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
isActive: boolean;
|
||||
/** Null on every agent that exists only as a GL-assignable roster entry. */
|
||||
email?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
/** True once an IAM account backs the agent — i.e. it can sign in. */
|
||||
hasAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteTransitAgentDto {
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** What the API reports back about an activation send. */
|
||||
export interface ActivationSendResult {
|
||||
maskedTarget: string;
|
||||
channel: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const transitAgentsService = {
|
||||
/** Active + currently inside its validity window — the assignment dropdown. */
|
||||
async listAssignable() {
|
||||
const response = await api.get<TransitAgent[]>(
|
||||
const response = await apiClient.get<TransitAgent[]>(
|
||||
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the portal account for an agent that has none and send its
|
||||
* activation link. Separate from the rule-engine CRUD update because it mints
|
||||
* an IAM user, which a field edit must never do implicitly.
|
||||
*/
|
||||
async invite(id: string, dto: InviteTransitAgentDto) {
|
||||
const response = await apiClient.post<{
|
||||
agent: TransitAgent;
|
||||
activationSentTo: string | null;
|
||||
}>(URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_INVITE(id), dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const response = await apiClient.post<ActivationSendResult>(
|
||||
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_RESEND_ACTIVATION(id),
|
||||
{ channel },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -201,6 +201,10 @@ export interface BookingDetail {
|
||||
cargoTotalWeightVgm: number;
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
/** NUMBER_OF_WAGONS bulk only: the wagon count the customer booked. */
|
||||
bulkRequestedWagons?: number | null;
|
||||
/** NUMBER_OF_WAGONS bulk only: informational item count entered with the weight. */
|
||||
bulkItemCount?: number | null;
|
||||
isHazardous: boolean;
|
||||
isReefer?: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
@@ -264,7 +268,9 @@ export interface BookingDetail {
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
cargoType?: BookingNamedRef;
|
||||
cargoType?: BookingNamedRef & {
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" | null;
|
||||
};
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Home,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Loader2,
|
||||
// MapPin,
|
||||
@@ -68,6 +69,10 @@ import {
|
||||
ShippingLineInvoicesPage,
|
||||
ShippingLineSettingsPage,
|
||||
} from "./pages/shipping-line";
|
||||
import {
|
||||
TransitAgentBookingsPage,
|
||||
TransitAgentOverviewPage,
|
||||
} from "./pages/transit-agent";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -143,15 +148,15 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||
const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
|
||||
useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Keyed off a positive shipping-line identification, never off "no company":
|
||||
// that is also true mid-fetch and on error, which would let customers slip
|
||||
// past onboarding whenever the request failed.
|
||||
const needsOnboarding = isShippingLine
|
||||
? false
|
||||
: !company || !onboardingCompleted;
|
||||
// Keyed off a positive shipping-line / transit-agent identification, never
|
||||
// off "no company": that is also true mid-fetch and on error, which would let
|
||||
// customers slip past onboarding whenever the request failed.
|
||||
const needsOnboarding =
|
||||
isShippingLine || isTransitAgent ? false : !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
@@ -199,13 +204,14 @@ function OnboardingGate() {
|
||||
* contract/company-shaped page that has no meaning for it.
|
||||
*/
|
||||
function RequireCustomer() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||
// throw the user into the wrong app.
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||
if (isTransitAgent) return <Navigate to="/transit-agent" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -218,6 +224,15 @@ function RequireShippingLine() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Transit-agent routes, closed to every other account kind. */
|
||||
function RequireTransitAgent() {
|
||||
const { isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (!isTransitAgent) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||
@@ -225,11 +240,15 @@ function RequireShippingLine() {
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||
href: isShippingLine
|
||||
? "/shipping-line"
|
||||
: isTransitAgent
|
||||
? "/transit-agent"
|
||||
: "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,6 +345,24 @@ const shippingLineSidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar for transit agents. Two entries only — the rest of the portal
|
||||
* (contracts, invoices, settings, support) is company-scoped and has no meaning
|
||||
* for an agent, so nothing is filtered in from the other lists.
|
||||
*/
|
||||
const transitAgentSidebarItems: SidebarItem[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
href: "/transit-agent",
|
||||
icon: <LayoutDashboard size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/transit-agent/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -337,6 +374,7 @@ const App = () => {
|
||||
reapplyProfile,
|
||||
isAuthenticated,
|
||||
isShippingLine,
|
||||
isTransitAgent,
|
||||
} = useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
@@ -478,6 +516,39 @@ const App = () => {
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Transit-agent app. Two pages, both empty for now, behind their
|
||||
own layout — there is no support widget because the chat is
|
||||
company-scoped and an agent has no company, exactly as for a
|
||||
shipping line. */}
|
||||
{isTransitAgent && (
|
||||
<Route element={<RequireTransitAgent />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={transitAgentSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/transit-agent"
|
||||
element={<TransitAgentOverviewPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/transit-agent/bookings"
|
||||
element={<TransitAgentBookingsPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
<Route
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import {
|
||||
companiesService,
|
||||
isShippingLineAccount,
|
||||
isTransitAgentAccount,
|
||||
} from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
@@ -184,10 +185,21 @@ const useAuth = () => {
|
||||
const isShippingLine = isShippingLineAccount(accountInfo);
|
||||
const shippingLine = isShippingLine ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line.
|
||||
const companyInfo = isShippingLine
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
/**
|
||||
* Transit agents share the portal with customers and shipping lines but have
|
||||
* no company, no external profile and no onboarding. Identified positively
|
||||
* from the backend's discriminator, for the same reason as the shipping line
|
||||
* above — never from "company is missing".
|
||||
*/
|
||||
const isTransitAgent = isTransitAgentAccount(accountInfo);
|
||||
const transitAgent = isTransitAgent ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line and for
|
||||
// a transit agent alike.
|
||||
const companyInfo =
|
||||
isShippingLine || isTransitAgent
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -314,6 +326,8 @@ const useAuth = () => {
|
||||
onboardingStep,
|
||||
isShippingLine,
|
||||
shippingLine,
|
||||
isTransitAgent,
|
||||
transitAgent,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -235,9 +235,9 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
|
||||
LOCO
|
||||
</Text>
|
||||
</Box>
|
||||
{wagons.map((w) => (
|
||||
{wagons.map((w, i) => (
|
||||
<Tooltip
|
||||
key={w.sequenceNo}
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
|
||||
STATUS_TONES[w.status]?.label ?? w.status
|
||||
}`}
|
||||
@@ -257,7 +257,7 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
|
||||
}}
|
||||
>
|
||||
<Text fz={10} fw={700} c="#6B7C8E">
|
||||
W{w.sequenceNo}
|
||||
W{i + 1}
|
||||
</Text>
|
||||
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{w.wagonNumber ?? "—"}
|
||||
@@ -302,12 +302,21 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
function WagonCard({
|
||||
wagon,
|
||||
displayNo,
|
||||
selectable,
|
||||
selected,
|
||||
shared,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: BookingWagonAllocation;
|
||||
/**
|
||||
* 1-based position in THIS booking's wagon list — what the customer sees.
|
||||
* Deliberately not `sequenceNo`, which is the wagon's slot in the shared
|
||||
* train set and so starts wherever the previous booking left off (and
|
||||
* leaves holes when wagons are cancelled). Cancellation keys off
|
||||
* `allocationId`, so this number is display-only.
|
||||
*/
|
||||
displayNo: number;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
/** Shared consolidation wagon — not selectable for cancellation. */
|
||||
@@ -333,7 +342,7 @@ function WagonCard({
|
||||
checked={!!selected}
|
||||
onChange={onToggle}
|
||||
color="orange"
|
||||
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
|
||||
aria-label={`Select wagon ${displayNo} for cancellation`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
@@ -354,7 +363,7 @@ function WagonCard({
|
||||
WAGON
|
||||
</Text>
|
||||
<Text fz={15} fw={800} lh={1.2}>
|
||||
{wagon.sequenceNo}
|
||||
{displayNo}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -727,7 +736,7 @@ export function WagonsTab({
|
||||
<CancelledWagonsSection rows={ownCancellations} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
|
||||
{wagons.map((w) => {
|
||||
{wagons.map((w, i) => {
|
||||
// The shared consolidation wagon carries this booking's lone 20ft —
|
||||
// its other half belongs to the partner booking, so it can never be
|
||||
// cancelled on its own (the server rejects it too).
|
||||
@@ -740,6 +749,7 @@ export function WagonsTab({
|
||||
<WagonCard
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
wagon={w}
|
||||
displayNo={i + 1}
|
||||
shared={isSharedWagon}
|
||||
selectable={
|
||||
canSelect &&
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileText,
|
||||
Lock,
|
||||
Paperclip,
|
||||
Search,
|
||||
Train,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { cv } from "@/pages/MyPortalPage/constants";
|
||||
import TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal";
|
||||
import {
|
||||
transitAssignmentsService,
|
||||
type TransitAssignment,
|
||||
type TransitAssignmentStatus,
|
||||
} from "@/services/transit-assignments.service";
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [
|
||||
{ value: "8", label: "8 / page" },
|
||||
{ value: "20", label: "20 / page" },
|
||||
{ value: "50", label: "50 / page" },
|
||||
];
|
||||
|
||||
/** Status pills use the portal's soft-tint / strong-ink pairs, not raw Mantine colours. */
|
||||
const STATUS_META: Record<
|
||||
TransitAssignmentStatus,
|
||||
{ label: string; bg: string; fg: string }
|
||||
> = {
|
||||
NOT_STARTED: {
|
||||
label: "Not started",
|
||||
bg: "edr-slate-soft",
|
||||
fg: "edr-slate",
|
||||
},
|
||||
IN_PROGRESS: { label: "In progress", bg: "edr-blue-soft", fg: "edr-blue" },
|
||||
FINISHED: { label: "Finished", bg: "edr-soft", fg: "edr-green.7" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "NOT_STARTED", label: "Not started" },
|
||||
{ value: "IN_PROGRESS", label: "In progress" },
|
||||
{ value: "FINISHED", label: "Finished" },
|
||||
];
|
||||
|
||||
const SHIPMENT_OPTIONS = [
|
||||
{ value: "DISPATCHED", label: "Dispatched (in transit)" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
];
|
||||
|
||||
/** Minutes as "5h 30m" — the raw integer is unreadable in a grid. */
|
||||
function formatMinutes(minutes: number | null): string {
|
||||
if (minutes === null) return "—";
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return hours ? `${hours}h ${rest}m` : `${rest}m`;
|
||||
}
|
||||
|
||||
/** Sentence-cases a SCREAMING_SNAKE enum for display. */
|
||||
function humanize(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const spaced = value.toLowerCase().replace(/_/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
soft,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number | string;
|
||||
soft: string;
|
||||
}) {
|
||||
return (
|
||||
<Box className="rounded-[20px] border border-edr-border bg-edr-card" p={16}>
|
||||
<Group gap={12} wrap="nowrap">
|
||||
<Box
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ background: cv(soft) }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box className="min-w-0">
|
||||
<Text
|
||||
fz={22}
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
c="edr-text"
|
||||
className="tracking-tight"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz={11} fw={600} c="edr-muted" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transit agent's work list: every booking assigned to them, with the
|
||||
* document action for each.
|
||||
*
|
||||
* Filtering and paging are server-side — the roster grows without bound, so
|
||||
* neither can depend on holding every row in the browser. Uploading is gated on
|
||||
* the API's own `canUploadDocuments`, never re-derived here, so a row's action
|
||||
* cannot promise something the server will reject.
|
||||
*/
|
||||
export default function TransitAgentBookingsPage() {
|
||||
const [active, setActive] = useState<TransitAssignment | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [shipment, setShipment] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
|
||||
// Any filter change invalidates the current page number: staying on page 3 of
|
||||
// a freshly narrowed result set shows an empty table.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, status, shipment, pageSize]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"transit-assignments",
|
||||
{ search: debouncedSearch, status, shipment, page, pageSize },
|
||||
],
|
||||
queryFn: () =>
|
||||
transitAssignmentsService.list({
|
||||
search: debouncedSearch || undefined,
|
||||
status: (status as TransitAssignmentStatus) ?? undefined,
|
||||
schedulingStatus: shipment ?? undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
// Keeps the previous page visible while the next one loads, so paging does
|
||||
// not flash an empty table.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
const meta = query.data?.meta;
|
||||
const hasFilters = !!(debouncedSearch || status || shipment);
|
||||
|
||||
// Counts describe the CURRENT PAGE, and say so — deriving totals from a
|
||||
// paginated slice would quietly under-report the agent's real workload.
|
||||
const pageCounts = useMemo(
|
||||
() => ({
|
||||
open: items.filter((a) => a.status !== "FINISHED").length,
|
||||
uploadable: items.filter((a) => a.canUploadDocuments).length,
|
||||
documents: items.reduce((sum, a) => sum + (a.files?.length ?? 0), 0),
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setStatus(null);
|
||||
setShipment(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Bookings</Title>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Shipments assigned to you for transit. Documents can be uploaded once
|
||||
a booking is dispatched, and are locked when you finish it.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 4 }} spacing="sm">
|
||||
<SummaryTile
|
||||
icon={<Train size={18} color={cv("edr-blue")} strokeWidth={2} />}
|
||||
label={meta ? "Assigned to you" : "Assigned"}
|
||||
value={meta?.total ?? "—"}
|
||||
soft="edr-blue-soft"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={
|
||||
<Clock3 size={18} color={cv("edr-amber-text")} strokeWidth={2} />
|
||||
}
|
||||
label="Open on this page"
|
||||
value={pageCounts.open}
|
||||
soft="edr-amber-soft"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={
|
||||
<CheckCircle2 size={18} color={cv("edr-green.7")} strokeWidth={2} />
|
||||
}
|
||||
label="Ready for documents"
|
||||
value={pageCounts.uploadable}
|
||||
soft="edr-soft"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={<Paperclip size={18} color={cv("edr-slate")} strokeWidth={2} />}
|
||||
label="Documents on this page"
|
||||
value={pageCounts.documents}
|
||||
soft="edr-slate-soft"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex="1 1 240px"
|
||||
label="Search"
|
||||
placeholder="Booking reference or customer"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
w={190}
|
||||
label="My status"
|
||||
placeholder="Any"
|
||||
data={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
w={210}
|
||||
label="Shipment"
|
||||
placeholder="Any"
|
||||
data={SHIPMENT_OPTIONS}
|
||||
value={shipment}
|
||||
onChange={setShipment}
|
||||
clearable
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-slate"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{query.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
) : query.isError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{(query.error as Error).message}
|
||||
</Alert>
|
||||
) : items.length === 0 ? (
|
||||
<Card withBorder radius="md" py={64}>
|
||||
<Stack align="center" gap="xs">
|
||||
<FileText size={28} opacity={0.4} />
|
||||
<Text fz={13} c="edr-muted">
|
||||
{hasFilters
|
||||
? "No bookings match these filters."
|
||||
: "No bookings have been assigned to you yet."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button variant="subtle" size="compact-sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<Table.ScrollContainer minWidth={880}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Shipment</Table.Th>
|
||||
<Table.Th>My status</Table.Th>
|
||||
<Table.Th ta="center">Docs</Table.Th>
|
||||
<Table.Th>Time after arrival</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((assignment) => {
|
||||
const statusMeta = STATUS_META[assignment.status];
|
||||
const docCount = assignment.files?.length ?? 0;
|
||||
const locked = !assignment.canUploadDocuments;
|
||||
return (
|
||||
<Table.Tr key={assignment.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{assignment.booking?.reference ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ maxWidth: 260 }}>
|
||||
<Tooltip
|
||||
label={assignment.customerName ?? "—"}
|
||||
disabled={!assignment.customerName}
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{assignment.customerName ?? "—"}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={7} wrap="nowrap">
|
||||
<Box
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
assignment.booking?.schedulingStatus ===
|
||||
"DISPATCHED"
|
||||
? cv("edr-blue-dot")
|
||||
: cv("edr-step-idle"),
|
||||
}}
|
||||
/>
|
||||
<Text fz={12} c="edr-text">
|
||||
{humanize(assignment.booking?.schedulingStatus)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Box
|
||||
px={9}
|
||||
py={3}
|
||||
className="inline-flex w-fit rounded-full"
|
||||
style={{ background: cv(statusMeta.bg) }}
|
||||
>
|
||||
<Text
|
||||
fz={10}
|
||||
fw={700}
|
||||
style={{ color: cv(statusMeta.fg) }}
|
||||
>
|
||||
{statusMeta.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Text size="sm" c={docCount ? undefined : "dimmed"}>
|
||||
{docCount || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="edr-muted">
|
||||
{formatMinutes(assignment.timeAfterTrainArrives)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={locked ? "subtle" : "light"}
|
||||
color={locked ? "gray" : undefined}
|
||||
leftSection={
|
||||
locked ? (
|
||||
<Lock size={13} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() => setActive(assignment)}
|
||||
>
|
||||
{locked ? "View" : "Documents"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{meta ? (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="md"
|
||||
wrap="wrap"
|
||||
gap="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fz={13} c="edr-muted">
|
||||
Showing {(meta.page - 1) * meta.pageSize + 1}–
|
||||
{Math.min(meta.page * meta.pageSize, meta.total)} of{" "}
|
||||
{meta.total}
|
||||
</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
w={110}
|
||||
aria-label="Rows per page"
|
||||
data={PAGE_SIZE_OPTIONS}
|
||||
value={String(pageSize)}
|
||||
onChange={(v) => setPageSize(Number(v) || PAGE_SIZE)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{/* Rendered even on a single page: the control disappearing as
|
||||
the result set shrinks reads as a broken table rather than as
|
||||
"there is only one page". */}
|
||||
<Pagination
|
||||
size="sm"
|
||||
value={meta.page}
|
||||
total={meta.totalPages}
|
||||
onChange={setPage}
|
||||
withEdges
|
||||
disabled={meta.totalPages <= 1}
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<TransitAgentDocumentsModal
|
||||
assignment={active}
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
FileButton,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { resolveViewerKind } from "@edr/ui-common";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
File as FileIcon,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Film,
|
||||
ImageIcon,
|
||||
Lock,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { fetchViewableFile, filesService } from "@/services/files.service";
|
||||
import {
|
||||
transitAssignmentsService,
|
||||
type TransitAssignment,
|
||||
type TransitAssignmentFile,
|
||||
} from "@/services/transit-assignments.service";
|
||||
|
||||
const formatBytes = (bytes: number): string =>
|
||||
bytes >= 1024 * 1024
|
||||
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
|
||||
/** Icon + colour per viewer kind, so a file's type reads at a glance. */
|
||||
function kindVisuals(name: string, mimeType?: string | null) {
|
||||
const kind = resolveViewerKind({ name, url: "", mimeType });
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return { icon: <ImageIcon size={18} />, color: "grape" };
|
||||
case "pdf":
|
||||
return { icon: <FileText size={18} />, color: "red" };
|
||||
case "video":
|
||||
case "audio":
|
||||
return { icon: <Film size={18} />, color: "indigo" };
|
||||
case "office":
|
||||
return { icon: <FileSpreadsheet size={18} />, color: "teal" };
|
||||
case "text":
|
||||
return { icon: <FileText size={18} />, color: "blue" };
|
||||
default:
|
||||
return { icon: <FileIcon size={18} />, color: "gray" };
|
||||
}
|
||||
}
|
||||
|
||||
/** One already-uploaded document. */
|
||||
function UploadedFileCard({
|
||||
file,
|
||||
locked,
|
||||
busy,
|
||||
onView,
|
||||
onRemove,
|
||||
}: {
|
||||
file: TransitAssignmentFile;
|
||||
locked: boolean;
|
||||
busy: boolean;
|
||||
onView: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const visuals = kindVisuals(file.name, file.mimeType);
|
||||
const isImage = (file.mimeType ?? "").startsWith("image/");
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Thumbnails are fetched through the API, not linked straight from `file.url`.
|
||||
* That URL points at MinIO, which is not reachable from the browser, and
|
||||
* `GET /api/files/:id` is JWT-guarded — so a bare `<img src>` gets either a
|
||||
* DNS failure or a 401. The bytes come down the authenticated axios client
|
||||
* and become a blob URL, revoked when the card unmounts.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isImage) return;
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
void filesService
|
||||
.download(file.id)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setThumb(objectUrl);
|
||||
})
|
||||
// A failed thumbnail is not worth surfacing — the card falls back to its
|
||||
// type icon and the preview button still reports any real error.
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [file.id, isImage]);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{isImage && thumb ? (
|
||||
// A thumbnail is worth more than an icon for scanned paperwork, which
|
||||
// is most of what gets filed here.
|
||||
<Image
|
||||
src={thumb}
|
||||
alt={file.name}
|
||||
w={44}
|
||||
h={44}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={visuals.color}
|
||||
size={44}
|
||||
radius="sm"
|
||||
>
|
||||
{visuals.icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip label={file.title || file.name} openDelay={400}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.title || file.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(file.size)} ·{" "}
|
||||
{new Date(file.uploadedAt).toLocaleString()}
|
||||
</Text>
|
||||
{file.uploadedByName ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
by {file.uploadedByName}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Preview">
|
||||
<ActionIcon variant="subtle" onClick={onView}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{locked ? null : (
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file chosen but not yet uploaded: local preview plus the name to file it under. */
|
||||
function PendingFileCard({
|
||||
file,
|
||||
title,
|
||||
previewUrl,
|
||||
onTitleChange,
|
||||
onView,
|
||||
onRemove,
|
||||
disabled,
|
||||
}: {
|
||||
file: File;
|
||||
title: string;
|
||||
previewUrl: string;
|
||||
onTitleChange: (title: string) => void;
|
||||
onView: () => void;
|
||||
onRemove: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const visuals = kindVisuals(file.name, file.type);
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="xs" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{isImage ? (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
w={36}
|
||||
h={36}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={visuals.color}
|
||||
size={36}
|
||||
radius="sm"
|
||||
>
|
||||
{visuals.icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Document name (optional)"
|
||||
value={title}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onTitleChange(e.currentTarget.value)}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file.name} · {formatBytes(file.size)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Tooltip label="Preview before uploading">
|
||||
<ActionIcon variant="subtle" onClick={onView}>
|
||||
<Eye size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove from selection">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents for one transit assignment: what has been filed, and what is about
|
||||
* to be.
|
||||
*
|
||||
* Uploading is gated on the server by two rules — the booking must be
|
||||
* dispatched, and the assignment must not be finished. Rather than
|
||||
* re-implementing them here, the modal reads the server's own
|
||||
* `canUploadDocuments`, so the disabled state can never disagree with what the
|
||||
* API would accept.
|
||||
*/
|
||||
export default function TransitAgentDocumentsModal({
|
||||
assignment,
|
||||
onClose,
|
||||
}: {
|
||||
assignment: TransitAssignment | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { view, viewer } = useFileViewer();
|
||||
/**
|
||||
* Files chosen but not yet uploaded, each with the name the agent gives it.
|
||||
* Kept as objects rather than two parallel arrays so removing one entry
|
||||
* cannot desynchronise a file from its title.
|
||||
*/
|
||||
const [pending, setPending] = useState<{ file: File; title: string }[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
/**
|
||||
* Object URLs for the staged files, keyed by File identity.
|
||||
*
|
||||
* Keyed rather than rebuilt as an array: removing one file from the selection
|
||||
* would otherwise re-create every URL and revoke the old ones, which kills the
|
||||
* preview of a DIFFERENT file if the viewer happens to be open on it. Only
|
||||
* URLs whose file has actually left the selection are revoked.
|
||||
*/
|
||||
const previewsRef = useRef(new Map<File, string>());
|
||||
const previews = useMemo(() => {
|
||||
const next = new Map<File, string>();
|
||||
for (const { file } of pending) {
|
||||
next.set(
|
||||
file,
|
||||
previewsRef.current.get(file) ?? URL.createObjectURL(file),
|
||||
);
|
||||
}
|
||||
for (const [file, url] of previewsRef.current) {
|
||||
if (!next.has(file)) URL.revokeObjectURL(url);
|
||||
}
|
||||
previewsRef.current = next;
|
||||
return next;
|
||||
}, [pending]);
|
||||
|
||||
// Last resort: revoke whatever is still held when the modal unmounts.
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of previewsRef.current.values()) URL.revokeObjectURL(url);
|
||||
previewsRef.current = new Map();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Each assignment opens a fresh sheet — files staged for one must not follow
|
||||
// the modal onto another.
|
||||
useEffect(() => {
|
||||
setPending([]);
|
||||
setNote(assignment?.note ?? "");
|
||||
}, [assignment?.id, assignment?.note]);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["transit-assignment", assignment?.id],
|
||||
queryFn: () => transitAssignmentsService.getById(assignment!.id),
|
||||
enabled: assignment !== null,
|
||||
});
|
||||
|
||||
const detail = detailQuery.data ?? assignment;
|
||||
const locked = !detail?.canUploadDocuments;
|
||||
const isFinished = detail?.status === "FINISHED";
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["transit-assignments"] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["transit-assignment", assignment?.id],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
transitAssignmentsService.uploadFiles(assignment!.id, pending),
|
||||
onSuccess: async () => {
|
||||
setPending([]);
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Save = persist everything the agent has entered, without closing the work.
|
||||
*
|
||||
* Staged files are uploaded FIRST, then the note is saved: a Save that left
|
||||
* the chosen files sitting in the browser would look like it had stored them,
|
||||
* and they would be silently lost on close. The status only ever moves to
|
||||
* IN_PROGRESS here — finishing is a separate, deliberate action.
|
||||
*/
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (pending.length > 0) {
|
||||
await transitAssignmentsService.uploadFiles(assignment!.id, pending);
|
||||
}
|
||||
return transitAssignmentsService.submit(assignment!.id, {
|
||||
finish: false,
|
||||
note,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending([]);
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (fileId: string) =>
|
||||
transitAssignmentsService.removeFile(assignment!.id, fileId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Save keeps the assignment open; finish closes it AND locks the documents,
|
||||
// which is why the button asks first.
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Flush anything still staged before closing: finishing locks uploads, so
|
||||
// a file left behind here could never be filed afterwards.
|
||||
if (pending.length > 0) {
|
||||
await transitAssignmentsService.uploadFiles(assignment!.id, pending);
|
||||
}
|
||||
return transitAssignmentsService.submit(assignment!.id, {
|
||||
finish: true,
|
||||
note,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending([]);
|
||||
await invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Open a stored document in the viewer.
|
||||
*
|
||||
* The bytes are fetched through the authenticated API rather than linked from
|
||||
* `file.url`: that column holds the MinIO object URL, which the browser
|
||||
* cannot reach, and `GET /api/files/:id` requires the Bearer token that a
|
||||
* raw `<iframe>`/`<img>` load would not carry.
|
||||
*/
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const openUploaded = (file: TransitAssignmentFile) => {
|
||||
setViewerError(null);
|
||||
void fetchViewableFile(file.id, file.title || file.name)
|
||||
.then((viewable) =>
|
||||
view({ ...viewable, mimeType: viewable.mimeType ?? file.mimeType }),
|
||||
)
|
||||
.catch((error: Error) =>
|
||||
setViewerError(error.message || "Could not open this document."),
|
||||
);
|
||||
};
|
||||
|
||||
const files = detail?.files ?? [];
|
||||
const busy =
|
||||
uploadMutation.isPending ||
|
||||
removeMutation.isPending ||
|
||||
saveMutation.isPending ||
|
||||
finishMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={assignment !== null}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>Documents</Text>
|
||||
<Text c="dimmed">{detail?.booking?.reference ?? ""}</Text>
|
||||
</Group>
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{detailQuery.isPending ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{detail?.customerName ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{detail.customerName}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isFinished ? (
|
||||
<Alert
|
||||
color="teal"
|
||||
variant="light"
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
>
|
||||
This assignment is finished. Its documents are locked and can no
|
||||
longer be added to or removed.
|
||||
</Alert>
|
||||
) : locked ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={16} />}
|
||||
>
|
||||
Documents can be uploaded once this booking has been dispatched.
|
||||
It is currently{" "}
|
||||
{detail?.booking?.schedulingStatus
|
||||
?.toLowerCase()
|
||||
.replace(/_/g, " ") ?? "not dispatched"}
|
||||
.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{viewerError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={16} />}
|
||||
>
|
||||
{viewerError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Uploaded
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" circle={files.length < 10}>
|
||||
{files.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<Card withBorder radius="md" py="lg">
|
||||
<Stack align="center" gap={4}>
|
||||
<FileIcon size={22} opacity={0.35} />
|
||||
<Text size="sm" c="dimmed">
|
||||
No documents uploaded yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap="xs">
|
||||
{files.map((file) => (
|
||||
<UploadedFileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
locked={locked}
|
||||
busy={busy}
|
||||
onView={() => openUploaded(file)}
|
||||
onRemove={() => removeMutation.mutate(file.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{locked ? null : (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600}>
|
||||
Add documents
|
||||
</Text>
|
||||
<FileButton
|
||||
multiple
|
||||
onChange={(chosen) =>
|
||||
setPending((current) => [
|
||||
...current,
|
||||
// Appended, not replaced: picking a second time must
|
||||
// add to the batch rather than discard the first pick
|
||||
// and the names already typed for it.
|
||||
...chosen.map((file) => ({ file, title: "" })),
|
||||
])
|
||||
}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
disabled={busy}
|
||||
>
|
||||
Choose files
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<>
|
||||
<ScrollArea.Autosize mah={180}>
|
||||
<Stack gap={6}>
|
||||
{pending.map((entry, index) => (
|
||||
<PendingFileCard
|
||||
key={`${entry.file.name}-${index}`}
|
||||
file={entry.file}
|
||||
title={entry.title}
|
||||
previewUrl={previews.get(entry.file) ?? ""}
|
||||
disabled={busy}
|
||||
onTitleChange={(title) =>
|
||||
setPending((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index ? { ...item, title } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
onView={() =>
|
||||
view({
|
||||
name: entry.title || entry.file.name,
|
||||
url: previews.get(entry.file) ?? "",
|
||||
mimeType: entry.file.type,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
setPending((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<Upload size={15} />}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
>
|
||||
Upload {pending.length} file
|
||||
{pending.length === 1 ? "" : "s"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={busy}
|
||||
onClick={() => setPending([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{uploadMutation.isError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={16} />}
|
||||
>
|
||||
{(uploadMutation.error as Error).message}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Anything worth recording about this clearance"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
loading={saveMutation.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{pending.length > 0
|
||||
? `Save & upload ${pending.length}`
|
||||
: "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Lock size={15} />}
|
||||
loading={finishMutation.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
// Finishing is one-way: it closes the assignment and locks
|
||||
// its documents, so confirm before doing it.
|
||||
if (
|
||||
window.confirm(
|
||||
pending.length > 0
|
||||
? `Upload ${pending.length} staged file(s) and finish this assignment? Its documents will then be locked.`
|
||||
: "Finish this assignment? Its documents will be locked and can no longer be changed.",
|
||||
)
|
||||
) {
|
||||
finishMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Box, Button, Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
FileCheck2,
|
||||
FileX2,
|
||||
Paperclip,
|
||||
Play,
|
||||
Target,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Card } from "@/pages/MyPortalPage/components";
|
||||
import { cv } from "@/pages/MyPortalPage/constants";
|
||||
import {
|
||||
transitAssignmentsService,
|
||||
type TransitStatItem,
|
||||
type TransitStats,
|
||||
} from "@/services/transit-assignments.service";
|
||||
|
||||
/** Minutes as a compact "3h 10m" / "45m" — raw integers are unreadable in a grid. */
|
||||
function formatMinutes(minutes: number | null): string {
|
||||
if (minutes === null) return "—";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return h ? `${h}h${m ? ` ${m}m` : ""}` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Split a duration so the number and its unit can be styled apart. */
|
||||
function splitDuration(minutes: number | null): [string, string] {
|
||||
if (minutes === null) return ["—", ""];
|
||||
if (minutes < 90) return [String(minutes), "min"];
|
||||
return [(minutes / 60).toFixed(1), "hrs"];
|
||||
}
|
||||
|
||||
const pctOf = (part: number, total: number) =>
|
||||
total ? Math.round((part / total) * 100) : 0;
|
||||
|
||||
type Tone = "green" | "amber" | "blue" | "slate" | "red";
|
||||
|
||||
const TONES: Record<Tone, { soft: string; ink: string }> = {
|
||||
green: { soft: cv("edr-soft"), ink: cv("edr-green.7") },
|
||||
amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") },
|
||||
blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") },
|
||||
slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") },
|
||||
red: { soft: cv("edr-red-soft"), ink: cv("edr-red") },
|
||||
};
|
||||
|
||||
/** Section heading shared by every panel, so the rhythm stays identical. */
|
||||
function PanelHead({
|
||||
title,
|
||||
hint,
|
||||
right,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" align="start" wrap="nowrap" mb="md">
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="edr-text">
|
||||
{title}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text fz={12} c="edr-muted" mt={2}>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{right}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One headline metric. Built on the portal's own KPI language — soft icon chip,
|
||||
* tight numeral, muted label — rather than a generic bordered box per stat.
|
||||
*/
|
||||
function Kpi({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
caption,
|
||||
tone,
|
||||
divider,
|
||||
}: {
|
||||
icon: typeof Timer;
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
caption: string;
|
||||
tone: Tone;
|
||||
divider?: boolean;
|
||||
}) {
|
||||
const t = TONES[tone];
|
||||
return (
|
||||
<Box
|
||||
className={
|
||||
divider
|
||||
? "flex flex-col border-t border-edr-divider pt-5 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0"
|
||||
: "flex flex-col"
|
||||
}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="start">
|
||||
<Box
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ background: t.soft }}
|
||||
>
|
||||
<Icon size={18} color={t.ink} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box className="min-w-0">
|
||||
<Group gap={5} align="baseline" wrap="nowrap">
|
||||
<Text
|
||||
fz={26}
|
||||
fw={800}
|
||||
lh={1.05}
|
||||
c="edr-text"
|
||||
className="tracking-tight"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{unit ? (
|
||||
<Text fz={12} fw={600} c="edr-muted">
|
||||
{unit}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={12} fw={600} c="edr-text" mt={6} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={11} c="edr-muted" truncate>
|
||||
{caption}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One booking's arrival→clearance track: a pickup-lag segment followed by the
|
||||
* clearance work, both on one shared scale so rows compare directly.
|
||||
*/
|
||||
function TimelineRow({
|
||||
item,
|
||||
scaleMax,
|
||||
}: {
|
||||
item: TransitStatItem;
|
||||
scaleMax: number;
|
||||
}) {
|
||||
const pickup = item.pickupMinutes ?? 0;
|
||||
const total = item.clearanceMinutes;
|
||||
const work = total !== null ? Math.max(total - pickup, 0) : 0;
|
||||
const pct = (v: number) => `${Math.min((v / scaleMax) * 100, 100)}%`;
|
||||
|
||||
const tone: Tone =
|
||||
total === null
|
||||
? "slate"
|
||||
: total <= 120
|
||||
? "green"
|
||||
: total <= 360
|
||||
? "amber"
|
||||
: "red";
|
||||
|
||||
return (
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Box className="w-[132px] shrink-0">
|
||||
<Text fz={12} fw={700} c="edr-text" className="font-mono">
|
||||
{item.reference?.replace("BK-2026-", "…") ?? "—"}
|
||||
</Text>
|
||||
<Text fz={10} c="edr-muted" truncate>
|
||||
{item.customerName ?? "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="flex h-8 flex-1 items-center overflow-hidden rounded-lg bg-edr-slate-soft2">
|
||||
{pickup > 0 ? (
|
||||
<Box
|
||||
className="h-8 shrink-0"
|
||||
style={{ width: pct(pickup), background: cv("edr-blue-dot") }}
|
||||
/>
|
||||
) : null}
|
||||
{total !== null ? (
|
||||
<Box
|
||||
className="h-8 shrink-0"
|
||||
style={{ width: pct(work), background: TONES[tone].ink }}
|
||||
/>
|
||||
) : (
|
||||
<Text fz={10} c="edr-muted" pl="sm">
|
||||
{item.status === "NOT_STARTED" ? "not started" : "in progress"}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
fz={12}
|
||||
fw={700}
|
||||
className="w-14 shrink-0 text-right font-mono"
|
||||
style={{ color: total === null ? cv("edr-muted") : TONES[tone].ink }}
|
||||
>
|
||||
{formatMinutes(total)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single SLA band as a labelled proportional bar. */
|
||||
function SlaBar({
|
||||
label,
|
||||
count,
|
||||
total,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
tone: Tone;
|
||||
}) {
|
||||
const pct = pctOf(count, total);
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: TONES[tone].ink }}
|
||||
/>
|
||||
<Text fz={12} fw={600} c="edr-text">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{count} · {pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box className="h-1.5 overflow-hidden rounded-full bg-edr-slate-soft2">
|
||||
<Box
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, background: TONES[tone].ink }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transit agent's dashboard: how quickly documents are filed after the
|
||||
* train dispatches and arrives.
|
||||
*
|
||||
* Every figure comes from `GET /transit-assignments/my/stats`, which derives
|
||||
* them from timestamps that already exist. The page renders what the API
|
||||
* measured rather than recomputing, so the two cannot disagree.
|
||||
*/
|
||||
export default function TransitAgentOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const query = useQuery({
|
||||
queryKey: ["transit-stats"],
|
||||
queryFn: transitAssignmentsService.stats,
|
||||
});
|
||||
|
||||
const s: TransitStats | undefined = query.data;
|
||||
|
||||
const timeline = useMemo(
|
||||
() =>
|
||||
(s?.items ?? [])
|
||||
.filter((i) => i.schedulingStatus === "DISPATCHED")
|
||||
.slice(0, 6),
|
||||
[s],
|
||||
);
|
||||
|
||||
// One shared scale, capped at 6h: a single 47h outlier would otherwise
|
||||
// compress every other row into an invisible sliver.
|
||||
const scaleMax = useMemo(() => {
|
||||
const measured = timeline
|
||||
.map((i) => i.clearanceMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
return Math.min(Math.max(...measured, 120) * 1.15, 360);
|
||||
}, [timeline]);
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<Center h={420}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || !s) {
|
||||
return (
|
||||
<Box p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Card>
|
||||
<Group gap="sm">
|
||||
<AlertCircle size={18} color={cv("edr-red")} />
|
||||
<Text fz={14} c="edr-text">
|
||||
{(query.error as Error)?.message ??
|
||||
"Could not load your overview."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { totals, performance, sla, coverage } = s;
|
||||
const [medianValue, medianUnit] = splitDuration(
|
||||
performance.medianClearanceMinutes,
|
||||
);
|
||||
const [pickupValue, pickupUnit] = splitDuration(
|
||||
performance.medianPickupMinutes,
|
||||
);
|
||||
const uncovered = coverage.dispatched - coverage.withDocuments;
|
||||
const coveragePct = pctOf(coverage.withDocuments, coverage.dispatched);
|
||||
|
||||
return (
|
||||
<Box className="bg-edr-bg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="end" wrap="wrap" gap="sm">
|
||||
<Box>
|
||||
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
|
||||
Clearance performance
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted" mt={4}>
|
||||
How fast documents are filed after the train dispatches and
|
||||
arrives
|
||||
</Text>
|
||||
</Box>
|
||||
{totals.open > 0 ? (
|
||||
<Group
|
||||
gap={8}
|
||||
px={12}
|
||||
py={7}
|
||||
className="rounded-full"
|
||||
style={{ background: cv("edr-soft") }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Box
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: cv("edr-green.6") }}
|
||||
/>
|
||||
<Text fz={12} fw={700} style={{ color: cv("edr-green.7") }}>
|
||||
{totals.open} active now
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Card>
|
||||
<Box className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kpi
|
||||
icon={Timer}
|
||||
label="Median clearance"
|
||||
value={medianValue}
|
||||
unit={medianUnit}
|
||||
caption={`across ${performance.measured} finished`}
|
||||
tone="green"
|
||||
/>
|
||||
<Kpi
|
||||
icon={Play}
|
||||
label="Pickup lag"
|
||||
value={pickupValue}
|
||||
unit={pickupUnit}
|
||||
caption="arrival → work started"
|
||||
tone="blue"
|
||||
divider
|
||||
/>
|
||||
<Kpi
|
||||
icon={Paperclip}
|
||||
label="Documents filed"
|
||||
value={String(totals.documents)}
|
||||
unit="files"
|
||||
caption={`across ${totals.assignments} bookings`}
|
||||
tone={uncovered > 0 ? "amber" : "green"}
|
||||
divider
|
||||
/>
|
||||
<Kpi
|
||||
icon={Target}
|
||||
label="On-time rate"
|
||||
value={
|
||||
performance.onTimeRate === null
|
||||
? "—"
|
||||
: String(performance.onTimeRate)
|
||||
}
|
||||
unit={performance.onTimeRate === null ? "" : "%"}
|
||||
caption="cleared within 6h"
|
||||
tone="green"
|
||||
divider
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Box className="lg:col-span-2">
|
||||
<Card>
|
||||
<PanelHead
|
||||
title="Arrival → clearance"
|
||||
hint="Time from the train arriving to documents filed"
|
||||
right={
|
||||
<Group gap={14} wrap="nowrap">
|
||||
{(
|
||||
[
|
||||
[cv("edr-blue-dot"), "pickup"],
|
||||
[cv("edr-green.7"), "cleared"],
|
||||
[cv("edr-red"), "breach"],
|
||||
] as const
|
||||
).map(([color, label]) => (
|
||||
<Group gap={6} key={label} wrap="nowrap">
|
||||
<Box
|
||||
className="size-2 rounded-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
{timeline.length === 0 ? (
|
||||
<Text fz={13} c="edr-muted" ta="center" py={40}>
|
||||
No dispatched bookings yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={14}>
|
||||
{timeline.map((item) => (
|
||||
<TimelineRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
scaleMax={scaleMax}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Stack gap="md">
|
||||
<Card>
|
||||
<PanelHead
|
||||
title="Clearance SLA"
|
||||
right={
|
||||
<Text fz={11} c="edr-muted">
|
||||
{performance.measured} measured
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Stack gap={14}>
|
||||
<SlaBar
|
||||
label="Under 2h"
|
||||
count={sla.under2h}
|
||||
total={performance.measured}
|
||||
tone="green"
|
||||
/>
|
||||
<SlaBar
|
||||
label="2h – 6h"
|
||||
count={sla.under6h}
|
||||
total={performance.measured}
|
||||
tone="amber"
|
||||
/>
|
||||
<SlaBar
|
||||
label="Over 6h"
|
||||
count={sla.over6h}
|
||||
total={performance.measured}
|
||||
tone="red"
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<PanelHead
|
||||
title="Document coverage"
|
||||
hint="Dispatched bookings with evidence filed"
|
||||
/>
|
||||
<Group align="baseline" gap={6} mb={10}>
|
||||
<Text
|
||||
fz={30}
|
||||
fw={800}
|
||||
lh={1}
|
||||
c="edr-text"
|
||||
className="tracking-tight"
|
||||
>
|
||||
{coveragePct}%
|
||||
</Text>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{coverage.withDocuments} of {coverage.dispatched}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box className="mb-4 h-2 overflow-hidden rounded-full bg-edr-slate-soft2">
|
||||
<Box
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${coveragePct}%`,
|
||||
background:
|
||||
uncovered > 0 ? cv("edr-amber-text") : cv("edr-green.6"),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{uncovered > 0 ? (
|
||||
<Group
|
||||
gap={10}
|
||||
p={12}
|
||||
align="start"
|
||||
wrap="nowrap"
|
||||
className="mb-4 rounded-xl"
|
||||
style={{ background: cv("edr-amber-soft") }}
|
||||
>
|
||||
<FileX2
|
||||
size={15}
|
||||
color={cv("edr-amber-text")}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Text
|
||||
fz={11}
|
||||
lh={1.5}
|
||||
style={{ color: cv("edr-amber-text") }}
|
||||
>
|
||||
{uncovered} dispatched booking
|
||||
{uncovered === 1 ? " has" : "s have"} no documents filed.
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group
|
||||
gap={10}
|
||||
p={12}
|
||||
align="start"
|
||||
wrap="nowrap"
|
||||
className="mb-4 rounded-xl"
|
||||
style={{ background: cv("edr-soft") }}
|
||||
>
|
||||
<FileCheck2
|
||||
size={15}
|
||||
color={cv("edr-green.7")}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Text fz={11} lh={1.5} style={{ color: cv("edr-green.7") }}>
|
||||
Every dispatched booking has evidence filed.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={() => navigate("/transit-agent/bookings")}
|
||||
>
|
||||
{uncovered > 0 ? "File missing documents" : "Open bookings"}
|
||||
</Button>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Card padding={0}>
|
||||
<Box px={24} pt={20} pb={14}>
|
||||
<PanelHead
|
||||
title="Recent assignments"
|
||||
right={
|
||||
<Text fz={11} c="edr-muted">
|
||||
{totals.assignments} total
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
{s.items.slice(0, 6).map((item) => (
|
||||
<Group
|
||||
key={item.id}
|
||||
justify="space-between"
|
||||
px={24}
|
||||
py={14}
|
||||
wrap="nowrap"
|
||||
className="border-t border-edr-divider"
|
||||
>
|
||||
<Group gap="md" wrap="nowrap" className="min-w-0 flex-1">
|
||||
<Text
|
||||
fz={12}
|
||||
fw={600}
|
||||
c="edr-text"
|
||||
className="w-[132px] font-mono"
|
||||
>
|
||||
{item.reference ?? "—"}
|
||||
</Text>
|
||||
<Box
|
||||
px={9}
|
||||
py={3}
|
||||
className="shrink-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
item.status === "FINISHED"
|
||||
? cv("edr-soft")
|
||||
: item.status === "IN_PROGRESS"
|
||||
? cv("edr-blue-soft")
|
||||
: cv("edr-slate-soft"),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fz={10}
|
||||
fw={700}
|
||||
style={{
|
||||
color:
|
||||
item.status === "FINISHED"
|
||||
? cv("edr-green.7")
|
||||
: item.status === "IN_PROGRESS"
|
||||
? cv("edr-blue")
|
||||
: cv("edr-slate"),
|
||||
}}
|
||||
>
|
||||
{item.status === "FINISHED"
|
||||
? "Finished"
|
||||
: item.status === "IN_PROGRESS"
|
||||
? "In progress"
|
||||
: "Not started"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={12} c="edr-muted" truncate>
|
||||
{item.customerName ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={22} wrap="nowrap" className="shrink-0">
|
||||
<Text fz={12} c="edr-muted" className="font-mono">
|
||||
{formatMinutes(item.clearanceMinutes)}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" className="w-10">
|
||||
{item.documentCount > 0 ? (
|
||||
<FileCheck2 size={13} color={cv("edr-green.6")} />
|
||||
) : (
|
||||
<FileX2 size={13} color={cv("edr-step-idle")} />
|
||||
)}
|
||||
<Text
|
||||
fz={12}
|
||||
className="font-mono"
|
||||
style={{
|
||||
color:
|
||||
item.documentCount > 0
|
||||
? cv("edr-green.7")
|
||||
: cv("edr-muted"),
|
||||
}}
|
||||
>
|
||||
{item.documentCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
|
||||
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";
|
||||
@@ -103,7 +103,7 @@ export interface CompanyProfileResponse {
|
||||
* "shipping line" would skip onboarding for customers whenever the request
|
||||
* failed. Absent (older responses) means `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
|
||||
|
||||
export interface CompanyInfoResponse {
|
||||
accountKind?: AccountKind;
|
||||
@@ -138,13 +138,38 @@ export interface ShippingLineInfoResponse {
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves both portal audiences. */
|
||||
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
|
||||
/**
|
||||
* A signed-in transit agent. Like a shipping line it has no company, no
|
||||
* external profile and no onboarding — the agent record itself is the account.
|
||||
*/
|
||||
export interface TransitAgentInfoResponse {
|
||||
accountKind: "transit_agent";
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
isActive: boolean;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
company: null;
|
||||
profile: null;
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves every portal audience. */
|
||||
export type AccountInfoResponse =
|
||||
| CompanyInfoResponse
|
||||
| ShippingLineInfoResponse
|
||||
| TransitAgentInfoResponse;
|
||||
|
||||
export const isShippingLineAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
|
||||
|
||||
export const isTransitAgentAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is TransitAgentInfoResponse => info?.accountKind === "transit_agent";
|
||||
|
||||
/** A staged profile-edit review request (portal view). */
|
||||
export interface ChangeRequestResponse {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
const BASE = "/api/transit-assignments/my";
|
||||
|
||||
/** One document attached to an assignment. */
|
||||
export interface TransitAssignmentFile {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
uploadedAt: string;
|
||||
updatedAt: string;
|
||||
uploadedByName: string | null;
|
||||
}
|
||||
|
||||
export type TransitAssignmentStatus =
|
||||
| "NOT_STARTED"
|
||||
| "IN_PROGRESS"
|
||||
| "FINISHED";
|
||||
|
||||
/**
|
||||
* One booking assigned to the signed-in transit agent.
|
||||
*
|
||||
* `canUploadDocuments` is computed server-side from BOTH gates (the booking is
|
||||
* dispatched, and the assignment is not finished). The UI reads that flag
|
||||
* rather than re-deriving the rule, so the button state cannot drift from what
|
||||
* the API will actually accept.
|
||||
*/
|
||||
export interface TransitAssignment {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
transitAgentId: string;
|
||||
status: TransitAssignmentStatus;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
assignedAt: string;
|
||||
note: string | null;
|
||||
timeAfterTrainArrives: number | null;
|
||||
canUploadDocuments: boolean;
|
||||
/** Whose cargo this is — flattened server-side off the booking's company. */
|
||||
customerName: string | null;
|
||||
files?: TransitAssignmentFile[];
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
status?: string | null;
|
||||
schedulingStatus?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
arrivedAt?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TransitAssignmentListParams {
|
||||
search?: string;
|
||||
status?: TransitAssignmentStatus;
|
||||
schedulingStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface TransitAssignmentListResult {
|
||||
items: TransitAssignment[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** One row behind the overview's timeline and activity list. */
|
||||
export interface TransitStatItem {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
customerName: string | null;
|
||||
status: TransitAssignmentStatus;
|
||||
schedulingStatus: string | null;
|
||||
transitMinutes: number | null;
|
||||
pickupMinutes: number | null;
|
||||
clearanceMinutes: number | null;
|
||||
documentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview figures, all derived server-side from existing timestamps. Every
|
||||
* duration is minutes, and null means "not measurable yet" rather than zero —
|
||||
* an unfinished assignment has no clearance time.
|
||||
*/
|
||||
export interface TransitStats {
|
||||
totals: {
|
||||
assignments: number;
|
||||
open: number;
|
||||
finished: number;
|
||||
readyForDocuments: number;
|
||||
documents: number;
|
||||
};
|
||||
performance: {
|
||||
medianClearanceMinutes: number | null;
|
||||
medianPickupMinutes: number | null;
|
||||
fastestClearanceMinutes: number | null;
|
||||
slowestClearanceMinutes: number | null;
|
||||
onTimeRate: number | null;
|
||||
measured: number;
|
||||
};
|
||||
sla: { under2h: number; under6h: number; over6h: number };
|
||||
coverage: { dispatched: number; withDocuments: number };
|
||||
items: TransitStatItem[];
|
||||
}
|
||||
|
||||
export const transitAssignmentsService = {
|
||||
/** Dashboard figures for the signed-in agent. */
|
||||
stats: async (): Promise<TransitStats> => {
|
||||
const { data } = await client.get(`${BASE}/stats`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bookings assigned to me. Filtering and paging are server-side: an agent's
|
||||
* roster grows without bound, so the page must not depend on holding every
|
||||
* row in the browser.
|
||||
*/
|
||||
list: async (
|
||||
params: TransitAssignmentListParams = {},
|
||||
): Promise<TransitAssignmentListResult> => {
|
||||
const { data } = await client.get(BASE, { params });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** One assignment, with its documents. */
|
||||
getById: async (id: string): Promise<TransitAssignment> => {
|
||||
const { data } = await client.get(`${BASE}/${id}`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload staged documents, each with its own display name.
|
||||
*
|
||||
* `titles` is positional: one entry appended per file, in the same order, so
|
||||
* the API can pair index N with file N. Sending them as a keyed object is not
|
||||
* possible here — two files may legitimately share a filename.
|
||||
*/
|
||||
uploadFiles: async (
|
||||
id: string,
|
||||
files: { file: File; title?: string }[],
|
||||
): Promise<TransitAssignmentFile[]> => {
|
||||
const formData = new FormData();
|
||||
// One field name for every file — the API keys each record by
|
||||
// `file.fieldname`, and these are free-form documents with no fixed slots.
|
||||
for (const entry of files) {
|
||||
formData.append("document", entry.file);
|
||||
formData.append("titles", entry.title?.trim() || "");
|
||||
}
|
||||
|
||||
const { data } = await client.post(`${BASE}/${id}/files`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
removeFile: async (id: string, fileId: string): Promise<void> => {
|
||||
await client.delete(`${BASE}/${id}/files/${fileId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Save progress, or finish. Finishing locks the assignment's documents, so
|
||||
* the caller should confirm before passing `finish: true`.
|
||||
*/
|
||||
submit: async (
|
||||
id: string,
|
||||
input: { finish: boolean; note?: string },
|
||||
): Promise<TransitAssignment> => {
|
||||
const { data } = await client.post(`${BASE}/${id}/submit`, input);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -481,6 +481,9 @@ importers:
|
||||
react-pdf-html:
|
||||
specifier: ^2.1.5
|
||||
version: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
||||
react-phone-number-input:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-quill-new:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3(quill-delta@5.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
Reference in New Issue
Block a user