feat(transit-agents): give transit agents a portal login

Optional IAM account per agent — nullable, nothing backfilled, so
roster-only rows keep working. Staff invite existing ones; new ones get
an account when an email is supplied. Activation reuses the shipping-line
link path. SMS reachability widens to Djibouti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-29 07:40:07 +00:00
parent 7e163088d9
commit 978f1889c6
18 changed files with 3828 additions and 22 deletions

View File

@@ -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,

View File

@@ -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`);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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(
@@ -1885,6 +1911,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...OVERVIEW_LAYOUT_PERMISSIONS,
...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS,
...TRANSIT_ASSIGNMENT_PERMISSIONS,
...CHAT_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
@@ -2113,6 +2140,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",

View File

@@ -1,14 +1,454 @@
import { Package } from "lucide-react";
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 ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
import { cv } from "@/pages/MyPortalPage/constants";
import TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentStatus,
} from "@/services/transit-assignments.service";
/** Empty by design for now — see {@link TransitAgentOverviewPage}. */
export default function TransitAgentBookingsPage() {
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 (
<ShippingLinePlaceholder
title="Bookings"
description="Shipments assigned to you for transit."
icon={<Package size={28} opacity={0.4} />}
/>
<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>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -1,18 +1,654 @@
import { LayoutDashboard } from "lucide-react";
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 ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
import { Card } from "@/pages/MyPortalPage/components";
import { cv } from "@/pages/MyPortalPage/constants";
import {
transitAssignmentsService,
type TransitStatItem,
type TransitStats,
} from "@/services/transit-assignments.service";
/**
* Empty by design for now. Reuses the shipping-line placeholder rather than a
* transit-agent copy of it: it is generic empty-state chrome, and duplicating it
* would mean two files to delete once either page gains real content.
*/
export default function TransitAgentOverviewPage() {
/** 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 (
<ShippingLinePlaceholder
title="Overview"
description="Your transit activity at a glance."
icon={<LayoutDashboard size={28} opacity={0.4} />}
/>
<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>
);
}

View File

@@ -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;
},
};