diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 397845cb3..a310e496b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts new file mode 100644 index 000000000..9d0116681 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts new file mode 100644 index 000000000..a21f75dd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts new file mode 100644 index 000000000..4c7089780 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts new file mode 100644 index 000000000..f6e91cd31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts new file mode 100644 index 000000000..166306f2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts new file mode 100644 index 000000000..2dda6b34e --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts new file mode 100644 index 000000000..dd7f07ac7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts new file mode 100644 index 000000000..76dd0eb9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts new file mode 100644 index 000000000..65edc5868 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts new file mode 100644 index 000000000..9030d58f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts @@ -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 { + constructor( + @InjectRepository(TransitAssignment) + private readonly assignmentsRepo: Repository, + ) { + 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, + 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, + 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 { + 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 { + return this.baseQuery() + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Every live assignment on one booking. */ + findByBooking(bookingId: string): Promise { + 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts new file mode 100644 index 000000000..f23a8426a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -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 = {}) => + ({ + 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[]) => { + 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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts new file mode 100644 index 000000000..389e08fac --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -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, + 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 { + 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 { + 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 { + 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(); + + 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 { + 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 { + await this.findMineById(userId, id); + } + + async uploadMyFiles( + userId: string, + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + titles?: string[], + ): Promise { + await this.assertMine(userId, id); + return this.uploadFiles(id, files, uploader, titles); + } + + async removeMyFile( + userId: string, + id: string, + fileId: string, + ): Promise { + 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 { + 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 { + 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 { + const current = await this.assignmentsRepository.findOneWithRelations(id); + if (!current) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + + const patch: Partial = {}; + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ac2507aa5..82e750ab1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx index 0942dc1d0..4e86a2de0 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx @@ -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 ( - } - /> + + + + {icon} + + + + {value} + + + {label} + + + + + ); +} + +/** + * 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(null); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const [status, setStatus] = useState(null); + const [shipment, setShipment] = useState(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 ( + + + Bookings + + Shipments assigned to you for transit. Documents can be uploaded once + a booking is dispatched, and are locked when you finish it. + + + + + } + label={meta ? "Assigned to you" : "Assigned"} + value={meta?.total ?? "—"} + soft="edr-blue-soft" + /> + + } + label="Open on this page" + value={pageCounts.open} + soft="edr-amber-soft" + /> + + } + label="Ready for documents" + value={pageCounts.uploadable} + soft="edr-soft" + /> + } + label="Documents on this page" + value={pageCounts.documents} + soft="edr-slate-soft" + /> + + + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + {hasFilters ? ( + + ) : null} + + + + {query.isPending ? ( +
+ +
+ ) : query.isError ? ( + }> + {(query.error as Error).message} + + ) : items.length === 0 ? ( + + + + + {hasFilters + ? "No bookings match these filters." + : "No bookings have been assigned to you yet."} + + {hasFilters ? ( + + ) : null} + + + ) : ( + + + + + + Booking + Customer + Shipment + My status + Docs + Time after arrival + + + + + {items.map((assignment) => { + const statusMeta = STATUS_META[assignment.status]; + const docCount = assignment.files?.length ?? 0; + const locked = !assignment.canUploadDocuments; + return ( + + + + {assignment.booking?.reference ?? "—"} + + + + + + {assignment.customerName ?? "—"} + + + + + + + + {humanize(assignment.booking?.schedulingStatus)} + + + + + + + {statusMeta.label} + + + + + + {docCount || "—"} + + + + + {formatMinutes(assignment.timeAfterTrainArrives)} + + + + + + + + + ); + })} + +
+
+ + {meta ? ( + + + + Showing {(meta.page - 1) * meta.pageSize + 1}– + {Math.min(meta.page * meta.pageSize, meta.total)} of{" "} + {meta.total} + +