diff --git a/apps/edr-freight-api/src/common/document-upload.options.ts b/apps/edr-freight-api/src/common/document-upload.options.ts new file mode 100644 index 000000000..736d8099b --- /dev/null +++ b/apps/edr-freight-api/src/common/document-upload.options.ts @@ -0,0 +1,30 @@ +import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface"; + +/** + * Ceiling for a single uploaded document, in bytes. + * + * Mirrors the 50MB `max_size_mb` the file-upload settings hand the portal, so + * the client-side gate and the server-side cap agree. Raising this alone is not + * enough to accept a 50MB upload: the reverse proxy in front of the API applies + * its own `client_max_body_size`, and nginx's 1MB default rejects the request + * with a 413 before it ever reaches Nest (see docs/uploads.md). + */ +export const DOCUMENT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; + +/** Upper bound on parts in one multipart document post. */ +export const DOCUMENT_UPLOAD_MAX_FILES = 20; + +/** + * Multer caps for the document upload routes. + * + * Without an explicit `fileSize`, multer's default is unlimited and every byte + * is buffered in memory, so an oversized post is absorbed in full before + * anything can reject it. With the limit set, multer stops reading the socket + * at the ceiling instead. + */ +export const documentUploadMulterOptions: MulterOptions = { + limits: { + fileSize: DOCUMENT_UPLOAD_MAX_BYTES, + files: DOCUMENT_UPLOAD_MAX_FILES, + }, +}; diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index c9a718f5d..f26cd19e7 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -16,11 +16,17 @@ import { AppModule } from "./app.module"; /** * JSON body ceiling. Signing posts the signature AND the company stamp as - * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is - * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp + * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is + * ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp * image with a 413 "request entity too large". + * + * Sized to clear the 50MB per-document ceiling + * (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the + * surrounding JSON. Note that the reverse proxy applies its own + * `client_max_body_size` and rejects oversized bodies before Nest sees them — + * raising this alone does not lift the limit end to end (see docs/uploads.md). */ -const JSON_BODY_LIMIT = "20mb"; +const JSON_BODY_LIMIT = "100mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as diff --git a/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts new file mode 100644 index 000000000..99d949e2e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Raises the per-field document ceiling from 10MB to 50MB. + * + * `max_size_mb` is what the portal enforces client-side (SmartFileInput blocks + * the file and shows "File size exceeds the limit of NMB"), so the seeded 10 + * was the visible limit for every existing form even after the server-side caps + * were lifted. The seeder only writes these rows on first insert, so deployed + * environments keep their old value until this runs. + * + * Only rows still sitting at the old default are touched — a field an admin has + * deliberately tuned to something else keeps that value. + */ +export class RaiseDocumentUploadSizeLimit3380000000000 + implements MigrationInterface +{ + name = "RaiseDocumentUploadSizeLimit3380000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 50 + `); + await queryRunner.query(` + UPDATE freight.file_upload_fields + SET max_size_mb = 50 + WHERE max_size_mb = 10 + `); + } + + /** + * Restores the column default only. The old per-row values are not + * recoverable (10 and an admin-chosen 10 are indistinguishable after `up`), + * and shrinking a customer's limit back down would reject documents they have + * already uploaded, so the rows are deliberately left at 50. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 10 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index de7a01484..1b14845b2 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -20,6 +20,7 @@ import { ApiOperation, ApiTags, ApiConsumes } 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, MixedAudience, PortalCustomer } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; import { assertFreightPermission, hasFreightPermission, @@ -726,7 +727,7 @@ export class CompaniesController { @Post(":companyId/documents") @MixedAudience(FREIGHT_PERMS.customers.update) - @UseInterceptors(AnyFilesInterceptor()) + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts index e76c5716b..097c1ec39 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -50,7 +50,7 @@ export class FileUploadField extends BaseEntity { }) allowedExtensions!: string[]; - @Column({ name: "max_size_mb", type: "integer", default: 10 }) + @Column({ name: "max_size_mb", type: "integer", default: 50 }) maxSizeMb!: number; @Column({ name: "display_order", type: "integer", default: 0 }) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts index 9085cc018..7f8a34175 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -46,7 +46,7 @@ export function poaDelegationField(displayOrder: number): FileUploadField { isMultiple: false, maxFiles: 1, allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 10, + maxSizeMb: 50, displayOrder, } as FileUploadField; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 23024bfdf..cb568171a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..85c879f70 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -14,7 +14,8 @@ import { * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * type currently in the source yard (enforced in the service, which is the only + * layer that can count them), and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..8f7457b37 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..6b63460ad 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -75,10 +75,11 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count IS capped by what the source yard can hand over right now: a request + * may not exceed the AVAILABLE, uncoupled wagons of that type in the source + * yard (the same number the yard desk shows). A reason is mandatory and is + * shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,6 +90,20 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 7bcae753b..512229706 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -37,7 +37,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -49,7 +49,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -60,7 +60,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, poaDelegationDefault(4), @@ -76,7 +76,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -87,7 +87,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -98,7 +98,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, { @@ -109,7 +109,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 4, }, poaDelegationDefault(5), @@ -127,7 +127,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 1, // }, // { @@ -138,7 +138,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 2, // }, // { @@ -149,7 +149,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 3, // }, // ]; @@ -232,7 +232,7 @@ function clearanceField( isMultiple: false, maxFiles: 1, allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder, }; } @@ -556,7 +556,7 @@ const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ isMultiple: true, maxFiles: 20, allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, ]; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..9b5cf91f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + + const hasAnything = + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {booking.selectedForBatchAt ? ( + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..b0b024977 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 4a92695ca..cb9fdf86b 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => { /** * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field - * for actions that move real wagons; omit it for a transfer REQUEST, which may - * legitimately ask for more than the yard holds today (OCC fulfils it in - * instalments) — the slider then just tracks the current value. + * to the wagons on hand; omitting it leaves the field unbounded and the slider + * simply tracks the current value. */ const QuantityField = ({ value, @@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro {availableCount} available - {/* No max: the request may exceed what the yard holds - today — OCC fulfils it in instalments. */} - + {/* Capped at the wagons actually available in this yard + right now (uncoupled + Available) — a request may not + ask for more than the yard can hand over. */} +