diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts new file mode 100644 index 000000000..7c95199b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Multi-truck customer (self-haul) assignment. Replaces the single + * booking.customer_truck_* fields with a per-booking list of trucks, each + * carrying 1–2 containers and tracking its own arrival. The legacy + * booking.customer_truck_* columns are kept as a synced booking-level flag + * (any truck assigned / all trucks arrived) so the warehouse exit-gate and + * delivery-approval logic keep working. + */ +export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignments1950000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + plate_number varchar(32) NOT NULL, + driver_name varchar(120) NOT NULL, + truck_type varchar(60) NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + arrived_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE, + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + container_number varchar(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`, + ); + // One container number can be loaded onto exactly one truck per booking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number" + ON freight.customer_truck_containers (booking_id, container_number) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts new file mode 100644 index 000000000..13084d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { CheckAvailabilityService } from "./check-availability.service"; + +@ApiTags("auth") +@Controller("auth") +@Public() +export class CheckAvailabilityController { + constructor( + private readonly checkAvailabilityService: CheckAvailabilityService, + ) {} + + @Get("check-availability") + @ApiOperation({ + summary: "Check whether an email and/or phone number is already registered", + }) + check(@Query("email") email?: string, @Query("phone") phone?: string) { + return this.checkAvailabilityService.check({ email, phone }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts new file mode 100644 index 000000000..c9ce84b72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +export interface CheckAvailabilityQuery { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResult { + emailTaken: boolean; + phoneTaken: boolean; +} + +@Injectable() +export class CheckAvailabilityService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async check({ + email, + phone, + }: CheckAvailabilityQuery): Promise { + if (!email && !phone) { + throw new BadRequestException("email or phone is required"); + } + + const matches = await this.userRepository.find({ + where: [ + ...(email ? [{ email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ], + select: { id: true, email: true, phoneNumber: true }, + }); + + return { + emailTaken: email ? matches.some((user) => user.email === email) : false, + phoneTaken: phone + ? matches.some((user) => user.phoneNumber === phone) + : false, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index a689ba24e..16fbeffda 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { CheckAvailabilityController } from './check-availability.controller'; +import { CheckAvailabilityService } from './check-availability.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - controllers: [FreightMeController], - providers: [FreightMeService], + imports: [TypeOrmModule.forFeature([User])], + controllers: [FreightMeController, CheckAvailabilityController], + providers: [FreightMeService, CheckAvailabilityService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c5574377a..106b7ed5b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -61,6 +62,11 @@ import { } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -83,6 +89,8 @@ export class BookingsController { private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, + private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -309,6 +317,94 @@ export class BookingsController { res.send(buffer); } + @Get(':id/customer-trucks') + @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + async listCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.listTrucks(id); + } + + @Post(':id/customer-trucks') + @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + async addCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addTruck(id, dto); + } + + @Delete(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + async removeCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.removeTruck(id, assignmentId); + } + + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 92790c273..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; +import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + CustomerTruckAssignment, + CustomerTruckContainer, ]), BillingModule, forwardRef(() => FirstMileModule), @@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CustomerTruckAssignmentsRepository, + CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService, + CustomerTruckService, + ContainerReceiptService, ], }) export class BookingsModule { } 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 e3d882baa..1f2c1a09e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -145,7 +145,28 @@ export class BookingsService { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } - const html = this.buildCustomerTruckFreightOrderHtml(booking); + const trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }> = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.arrived_at AS "arrivedAt", + string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at + ORDER BY a.assigned_at`, + [bookingId], + ); + + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); const buffer = await this.contractPdfService.htmlToPdfBuffer(html); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, @@ -190,37 +211,85 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } - private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + private buildCustomerTruckFreightOrderHtml( + booking: Booking, + trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }>, + ): string { const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const rows: Array<[string, string | null | undefined]> = [ + const bookingRows: Array<[string, string | null | undefined]> = [ ['Booking Reference', booking.reference], ['Client Name', booking.company?.name], ['Client ID', booking.companyId], ['Trade Direction', booking.tradeDirection], ['Freight Type', booking.freightType], - ['Truck Plate Number', booking.customerTruckPlateNumber], - ['Driver Name', booking.customerTruckDriverName], - ['Truck Type', booking.customerTruckType], - ['Container Number to Load', booking.customerTruckContainerNumber], ['Assigned At', assignedAt], ['Booking Status', booking.status], ]; - const rowHtml = rows + const bookingRowHtml = bookingRows .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) .join(''); + + // Fall back to the legacy single-truck booking columns when there are no + // multi-truck rows (bookings assigned before the multi-truck feature). + const truckList = + trucks.length > 0 + ? trucks + : booking.customerTruckPlateNumber + ? [ + { + plateNumber: booking.customerTruckPlateNumber, + driverName: booking.customerTruckDriverName ?? '', + truckType: booking.customerTruckType ?? '', + arrivedAt: booking.customerTruckArrivedAt + ? String(booking.customerTruckArrivedAt) + : null, + containers: booking.customerTruckContainerNumber ?? null, + }, + ] + : []; + + const truckBlocks = truckList + .map((t, i) => { + const rows: Array<[string, string | null | undefined]> = [ + ['Truck Plate Number', t.plateNumber], + ['Driver Name', t.driverName], + ['Truck Type', t.truckType], + ['Containers Loaded', t.containers], + [ + 'Arrival', + t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', + ], + ]; + const html = rows + .map( + ([label, value]) => + `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, + ) + .join(''); + return `

Truck ${i + 1}

${html}
`; + }) + .join(''); + const copy = (watermark: string) => `
${this.escapeHtml(watermark)}

Freight Order

-

Customer external truck assignment

+

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

${this.escapeHtml(booking.reference)}
- ${rowHtml}
+ ${bookingRowHtml}
+ ${truckBlocks}
Customer / Carrier Signature
Port Operations Verification
@@ -238,11 +307,13 @@ export class BookingsService { .watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; } header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; } h1 { margin: 0; font-size: 28px; letter-spacing: 0; } + h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; } p { margin: 4px 0 0; color: #64748b; } strong { font-size: 16px; color: #0a9f6a; } - table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; } + table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; } th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; } th { width: 34%; background: #f1f5f9; } + .truck { page-break-inside: avoid; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; } .signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; } diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts new file mode 100644 index 000000000..45a09a6a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; + +@Injectable() +export class CustomerTruckAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(CustomerTruckAssignment) + private readonly repo: Repository, + ) { + super(repo); + } + + /** All trucks assigned to a booking, oldest first, with their containers. */ + findByBookingId(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { containers: true }, + order: { assignedAt: 'ASC' }, + }); + } + + findByIdWithContainers(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { containers: true } }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts new file mode 100644 index 000000000..5d0650219 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -0,0 +1,321 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; + +interface BookingGuardRow { + tradeDirection: string | null; + firstMile: string | null; + lastMile: string | null; + paymentStatus: string | null; + status: string | null; +} + +/** + * Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg + * can have several customer trucks, each carrying 1–2 of its containers and + * tracking its own arrival. The legacy booking.customer_truck_* columns are kept + * as a booking-level flag (any truck assigned / all arrived) so the warehouse + * exit-gate + delivery-approval logic keep working unchanged. + */ +@Injectable() +export class CustomerTruckService { + constructor( + private readonly dataSource: DataSource, + private readonly assignments: CustomerTruckAssignmentsRepository, + ) {} + + listTrucks(bookingId: string): Promise { + return this.assignments.findByBookingId(bookingId); + } + + async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); + } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + const assignment = await manager.getRepository(CustomerTruckAssignment).save( + manager.getRepository(CustomerTruckAssignment).create({ + bookingId, + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }), + ); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId: assignment.id, + bookingId, + containerNumber, + }), + ), + ); + // Booking-level flag: first truck marks the booking as truck-assigned. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()), + status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + }); + + return this.listTrucks(bookingId); + } + + async removeTruck(bookingId: string, assignmentId: string): Promise { + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot remove a truck that has already arrived'); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId); + const remaining = await manager + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (remaining === 0) { + // No trucks left — clear the booking-level flag and revert the status. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = NULL, + status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } + }); + + return this.listTrucks(bookingId); + } + + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + + /** + * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse + * receive flow. When every truck on the booking has arrived, the booking-level + * customer_truck_arrived_at flag is stamped (used by the delivery-approval + * gate). No-op when the container is not on any customer truck. + */ + async markArrivedByContainer( + bookingId: string, + containerNumber: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const cn = containerNumber.trim().toUpperCase(); + const container = await m.getRepository(CustomerTruckContainer).findOne({ + where: { bookingId, containerNumber: cn }, + }); + if (!container) return; + + await m + .getRepository(CustomerTruckAssignment) + .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + + await this.syncBookingArrival(bookingId, m); + } + + /** Mark every truck on the booking arrived (fallback when no container is known). */ + async markAllArrived(bookingId: string, manager?: EntityManager): Promise { + const m = manager ?? this.dataSource.manager; + await m + .getRepository(CustomerTruckAssignment) + .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + await this.syncBookingArrival(bookingId, m); + } + + /** + * Stamp the booking-level arrival flag on the FIRST truck arrival. The import + * handover is signed once, before the first truck leaves, even though trucks + * pick up per-container — so the flag fires on the first arrival (COALESCE + * keeps it), not once all trucks have arrived. + */ + private async syncBookingArrival(bookingId: string, m: EntityManager): Promise { + await m.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`, + [bookingId], + ); + } + + private async loadBookingGuard(bookingId: string): Promise { + const [row]: BookingGuardRow[] = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection", + first_mile_pickup_address AS "firstMile", + last_mile_delivery_address AS "lastMile", + payment_status AS "paymentStatus", + status + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); + return row; + } + + private assertSelfHaulPaid(booking: BookingGuardRow): void { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + 'Booking must be paid before assigning an external customer truck', + ); + } + } + + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts new file mode 100644 index 000000000..4356d66ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -0,0 +1,47 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +/** + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. + */ +export class AddCustomerTruckDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts new file mode 100644 index 000000000..6eeaba963 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Booking } from './booking.entity'; +import { CustomerTruckContainer } from './customer-truck-container.entity'; + +/** + * One external (self-haul) truck a customer assigns to a booking that has no + * EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own + * arrival at the terminal/warehouse. + */ +@Entity({ schema: 'freight', name: 'customer_truck_assignments' }) +@Index(['bookingId']) +export class CustomerTruckAssignment extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'plate_number', type: 'varchar', length: 32 }) + plateNumber!: string; + + @Column({ name: 'driver_name', type: 'varchar', length: 120 }) + driverName!: string; + + @Column({ name: 'truck_type', type: 'varchar', length: 60 }) + truckType!: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' }) + assignedAt!: Date; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) + containers?: CustomerTruckContainer[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts new file mode 100644 index 000000000..110e31671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { CustomerTruckAssignment } from './customer-truck-assignment.entity'; + +/** + * A container number loaded onto a customer truck. A container may be loaded + * onto exactly one truck per booking (enforced by a partial unique index on + * booking_id + container_number). + */ +@Entity({ schema: 'freight', name: 'customer_truck_containers' }) +@Index(['assignmentId']) +export class CustomerTruckContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: CustomerTruckAssignment; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 5c993d38a..e72308a4d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1567,9 +1567,7 @@ export class TrainSchedulingService { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does not block departure (see assertImportDjiboutiMayDepart). if (schedule.status === TrainScheduleStatusEnum.Scheduled) { await this.dispatchSchedule(schedule.id); @@ -1946,9 +1944,9 @@ export class TrainSchedulingService { where: { trainScheduleId: schedule.id }, }); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation?.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does NOT gate dispatch. Per-booking loading is + // tracking only and the loaded-on-train step is optional — a scheduled train + // dispatches without waiting on loading. } private async getImportDjiboutiSchedule(scheduleId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ac6b71c89..c9844051b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -920,6 +920,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -1774,6 +1791,26 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2068,6 +2105,29 @@ export class WarehouseInventoryService { notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); if (!isTruckLeaving && item.bookingId) { + // Per-truck arrival: mark the customer truck carrying THIS item's + // container as arrived (matched via the physical container number). + if (item.containerId) { + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + FROM freight.customer_truck_containers c + JOIN freight.containers cont ON cont.container_number = c.container_number + WHERE c.assignment_id = a.id + AND c.deleted_at IS NULL + AND c.booking_id = $1 + AND cont.id = $2 + AND a.arrived_at IS NULL + AND a.deleted_at IS NULL`, + [item.bookingId, item.containerId], + ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. + } + // Booking-level flag stamped on the FIRST truck arrival. The import + // handover is signed ONCE (before the first truck leaves), even though + // trucks pick up per-container — COALESCE keeps the first timestamp. await manager.query( `UPDATE freight.bookings SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), @@ -2147,6 +2207,48 @@ export class WarehouseInventoryService { } await this.invoices.assertClearanceAllowed(id); + // Import self-haul: the exit paper names the pickup truck + all containers it + // carries, so gate staff can verify the goods leaving on that truck. + let truck: { + plateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string; + truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; + } | null = null; + if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = c.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_containers c + JOIN freight.customer_truck_assignments a + ON a.id = c.assignment_id AND a.deleted_at IS NULL + JOIN freight.customer_truck_containers c2 + ON c2.assignment_id = a.id AND c2.deleted_at IS NULL + WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id + LIMIT 1`, + [row.bookingId, row.containerNumber], + ); + truck = truckRow ?? null; + } + const bookingReference = row?.bookingReference || 'N/A'; const reference = row?.releaseOrderReference || @@ -2170,6 +2272,17 @@ export class WarehouseInventoryService { inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', exitInspectionSummary: this.extractExitInspectionNote(row?.notes), + truckPlateNumber: truck?.plateNumber ?? null, + truckDriverName: truck?.driverName ?? null, + truckType: truck?.truckType ?? null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -3099,6 +3212,11 @@ export class WarehouseInventoryService { inventoryStatus: string | null; clearanceStatus: string; exitInspectionSummary?: string | null; + truckPlateNumber?: string | null; + truckDriverName?: string | null; + truckType?: string | null; + truckGateOut?: string | null; + truckWeightKg?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -3123,12 +3241,37 @@ export class WarehouseInventoryService { ['Container Number', data.containerNumber], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Declared Weight', `${data.weight.toLocaleString()} kg`], + [ + data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', + `${(data.truckPlateNumber && data.truckWeightKg + ? data.truckWeightKg + : data.weight + ).toLocaleString()} kg`, + ], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.truckPlateNumber + ? ([ + ['Pickup Truck Plate', data.truckPlateNumber], + ['Truck Driver', data.truckDriverName], + ['Truck Type', data.truckType], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], + ] as [string, string | null][]) + : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.png b/apps/edr-freight-web/backoffice/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 92784cd38..f7b26af00 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -23,6 +23,7 @@ import { Users, Wallet, } from "lucide-react"; +import { useEffect } from "react"; import { Navigate, Outlet, @@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "User Management", + label: "Staff", href: "/um", icon: , }, { - label: "Booking requests", + label: "Bookings", href: "/dashboard/booking-requests", icon: , }, { - label: "Contract requests", + label: "Contracts", href: "/dashboard/contract-requests", icon: , permission: FREIGHT_PERMS.contracts.view, @@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Operations", items: [ { - label: "Document Clearance", + label: "Clearance", href: "/dashboard/contracts/clearance", icon: , permission: [ @@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Port & Terminal", items: [ { - label: "Import Operations", + label: "Imports", href: "/dashboard/import-warehouse", icon: , children: [ @@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - label: "Export Operations", + label: "Exports", href: "/dashboard/export-warehouse", icon: , children: [ @@ -516,6 +517,38 @@ const filterSidebarByPermission = ( .filter((section) => section.items.length > 0); }; +const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -541,6 +574,14 @@ const DashboardShell = () => { : null : null; + useEffect(() => { + const activeLabel = findActiveSidebarLabel( + location.pathname, + sidebarSections, + ); + document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; + }, [location.pathname, sidebarSections]); + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..c4fd7f39e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from "react"; +import { Box, Image, Stack, Text, Title } from "@mantine/core"; +import { ChevronDown, Globe } from "lucide-react"; + +const EDR_IMAGE = "/assets/edr_image.png"; +const EDR_LOGO = "/assets/logo.svg"; + +/** Muted deep-green brand wash for the left panel. */ +const LEFT_PANEL_BG = + "linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)"; + +/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */ +const IMAGE_FADE_MASK = + "linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)"; + +export interface AuthShellProps { + children: ReactNode; + /** Headline shown in the top-left of the green panel. */ + tagline?: string; + taglineBody?: string; +} + +const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( + + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight + + + + {tagline ?? "Ethiopian Djibouti Railway"} + + + {taglineBody ?? + "Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + +); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 59efe49a7..391a8157d 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -18,6 +18,7 @@ import { } from "react"; import type { SidebarItem, SidebarSection } from "./types"; +import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg"; const navClassNames = (active: boolean) => active ? { - root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", - label: "text-edr-primary-dark! font-medium! text-sm!", - section: "text-edr-primary-dark!", - } + root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } : { - root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", - label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", - section: "text-edr-text!", - }; + root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; const itemKey = (parentKey: string, item: SidebarItem, index: number) => `${parentKey}/${item.href ?? item.label}/${index}`; @@ -65,7 +66,9 @@ const FreightSidebar = ({ const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return activePath === normalized || activePath.startsWith(`${normalized}/`); + return ( + activePath === normalized || activePath.startsWith(`${normalized}/`) + ); }, [activePath], ); @@ -109,9 +112,7 @@ const FreightSidebar = ({ if (hasChildren) { const isLink = !!item.href; - const active = - (isLink ? isHrefActive(item.href!) : false) || - branchActive(item.children!); + const active = isLink ? isHrefActive(item.href!) : false; const isOpen = openMap[key] ?? false; return ( @@ -124,7 +125,7 @@ const FreightSidebar = ({ active={active} opened={isOpen} classNames={navClassNames(active)} - onClick={ () => toggle(key)} + onClick={() => toggle(key)} rightSection={ } @@ -161,8 +164,9 @@ const FreightSidebar = ({ label={item.label} leftSection={item.icon} active={active} + component={Link} classNames={navClassNames(active)} - onClick={() => onNavigate?.(item.href!)} + to={item.href!} /> ); }, @@ -178,7 +182,7 @@ const FreightSidebar = ({ tt="uppercase" px="sm" mb={6} - className={ "text-edr-muted!" } + className={"text-edr-muted!"} style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }} > {section.title} @@ -232,14 +236,24 @@ const FreightSidebar = ({ {onClose && ( - + )} {/* Nav */} - + {renderedSections} diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 32b94f325..781ffba74 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -27,7 +27,6 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - refetchOnWindowFocus: false, staleTime: 30_000, }, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 89e8557c3..849049bc2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,162 +1,40 @@ import { type FormEvent, useState } from "react"; import { - Eye, - EyeOff, - ArrowUpRight, - Globe, - ChevronDown, -} from "lucide-react"; + Alert, + Box, + Button, + Center, + Group, + Image, + PasswordInput, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { AlertCircle, ArrowLeft } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ const normaliseIdentifier = (raw: string): string => { const v = raw.trim(); const digits = v.replace(/\D/g, ""); if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + const local = digits.startsWith("251") + ? digits.slice(3) + : digits.replace(/^0/, ""); return `+251${local}`; } return v.toLowerCase(); }; -const LOGIN_IMAGE = "/assets/login.png"; const EDR_LOGO = "/assets/logo.svg"; -const fieldClass = - "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; - -const primaryButtonClass = - "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; - -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - -const RightPanelDecor = () => ( -
-
-
- - - - - - - - -
-); - -const LeftPanel = () => ( -
- Ethio Djibouti Railway -
- - - - -
-
-
-
- - Empower Your Freight Operations - -
-

- Sign in to manage bookings, track cargo, and run logistics operations - on the Ethio Djibouti Railway freight platform. -

-
-
-
-); - -const LanguageSelector = () => ( -
- - Eng - -
-); - -const FormFooter = () => ( - -); - const LoginPage = () => { const navigate = useNavigate(); const { login, verifyMfa } = useAuth(); @@ -165,7 +43,6 @@ const LoginPage = () => { const [otp, setOtp] = useState(""); const [needsMfa, setNeedsMfa] = useState(false); const [submitting, setSubmitting] = useState(false); - const [showPassword, setShowPassword] = useState(false); const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [error, setError] = useState(null); @@ -179,15 +56,14 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; } // navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to sign in with those credentials."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } @@ -201,194 +77,132 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to verify the one-time code."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } }; const loginForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

- Get Started -

-

+ + + Welcome back! + + Log in to access the freight backoffice & explore all logistics resources. -

-
+ + -
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + +
); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/portal/public/assets/edr_image.jpg b/apps/edr-freight-web/portal/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/portal/public/assets/edr_image.png b/apps/edr-freight-web/portal/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx index 5a4adf587..439f41d19 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx @@ -1,40 +1,25 @@ import type { ReactNode } from "react"; -import { ArrowUpRight, ChevronDown, Globe } from "lucide-react"; +import { Box, Image, Stack, Text, Title } from "@mantine/core"; +import { ChevronDown, Globe } from "lucide-react"; +import { Link } from "react-router-dom"; -const LOGIN_IMAGE = "/assets/login.png"; +const EDR_IMAGE = "/assets/edr_image.png"; const EDR_LOGO = "/assets/logo.svg"; +/** Muted deep-green brand wash for the left panel. */ +const LEFT_PANEL_BG = + "linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)"; + +/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */ +const IMAGE_FADE_MASK = + "linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)"; + export const fieldClass = "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; export const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - const RightPanelDecor = () => (
( export interface AuthShellProps { children: ReactNode; - /** Tagline shown in the highlighted card over the left image panel. */ + /** Headline shown in the top-left of the green panel. */ tagline?: string; taglineBody?: string; } @@ -72,45 +57,63 @@ const LeftPanel = ({ tagline, taglineBody, }: Pick) => ( -
- Ethio Djibouti Railway -
- - -
- + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight - - Support - - -
-
-
-
-
- - {tagline ?? "Empower Your Freight Operations"} - -
-

+ + + {tagline ?? "Ethiopian Djibouti Railway"} + + {taglineBody ?? - "Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."} -

-
-
-
+ "Sign in to book shipments, track cargo, and manage your freight on the Ethio–Djibouti Railway platform."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + ); const LanguageSelector = () => ( @@ -125,24 +128,24 @@ const FormFooter = () => ( ); @@ -169,8 +172,8 @@ export default function AuthShell({
-
-
+
+
{children}
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 98efc2613..6c38bba46 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -7,9 +7,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; +import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { @@ -22,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; } +const isValidTin = (tin: string) => tin.length === 10; + export default function ETradeInfo({ tin, register, @@ -30,53 +34,100 @@ export default function ETradeInfo({ }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasData = mutation.data; + const tinTaken = mutation.data?.tinTaken; + const hasData = + mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { - if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; + if (!isValidTin(tin)) return; const result = await mutation.mutateAsync(tin); - if (result) { + if (result && !result.tinTaken) { onDataLoaded(result); } }; - const errorMessage = + // Auto-fetch as soon as the TIN reaches its full 10-digit length — only + // once per distinct value, so retyping the same TIN doesn't refetch. + const lastFetchedTin = useRef(null); + useEffect(() => { + if (isValidTin(tin) && lastFetchedTin.current !== tin) { + lastFetchedTin.current = tin; + handleFetch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tin]); + + const apiError = mutation.isError && mutation.error - ? (mutation.error as any).message || - "Failed to fetch company information. Please try again." + ? extractApiError(mutation.error) + : null; + // A 400 here means eTrade simply has no record for this TIN — not a + // failure. Soft-pedal it as an FYI, not a red error, so filling in + // manually doesn't feel like something went wrong. + const notFound = apiError?.statusCode === 400; + const errorMessage = + apiError && !notFound + ? apiError.message || + "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." : null; return ( TIN Number (10 digits) *} + label={ + <> + TIN Number (10 digits){" "} + * + + } placeholder="0012345678" maxLength={10} error={error} {...register} /> - + {errorMessage && ( + + )} + {notFound && ( + } color="gray"> + We couldn't find a matching business record for this TIN — no + problem, just fill in the details below. + + )} + {errorMessage && ( } color="red" - title="Failed to fetch data" + title="Couldn't fetch eTrade data" > - {errorMessage} You can still fill in the details manually below. + {errorMessage} + + )} + + {tinTaken && ( + } + color="red" + title="TIN already registered" + > + This TIN is already registered to another company account. Please + double-check the number, or contact support if you believe this is a + mistake. )} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 451222841..fa1061622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -70,6 +70,8 @@ interface RoleLicenseStepProps { /** Newly-selected files per profile id (not yet uploaded). */ value: Record; onChange: (value: Record) => void; + /** "Business license is required" style error, keyed by profile id. */ + errors?: Record; } /** @@ -82,6 +84,7 @@ export default function RoleLicenseStep({ profiles, value, onChange, + errors, }: RoleLicenseStepProps) { const setFiles = (profileId: string, files: File[]) => { onChange({ ...value, [profileId]: files }); @@ -123,6 +126,11 @@ export default function RoleLicenseStep({ file={buildLicenseSetting(profile.id, label)} value={{ [LICENSE_FILE_KEY]: selected }} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} + errors={ + errors?.[profile.id] + ? { [LICENSE_FILE_KEY]: errors[profile.id] } + : undefined + } onChange={(v) => { const next = v[LICENSE_FILE_KEY]; const files = Array.isArray(next) ? next : next ? [next] : []; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ee8abe106..ac00e71a0 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -14,6 +14,7 @@ export const URL_CONSTANTS = { SET_PASSWORD: "/api/auth/set-password", ME: "/api/auth/me", GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", + CHECK_AVAILABILITY: "/api/auth/check-availability", }, OTP: { @@ -106,6 +107,9 @@ export const URL_CONSTANTS = { CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`, CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, + CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`, + CUSTOMER_TRUCK: (id: string, assignmentId: string) => + `/api/bookings/${id}/customer-trucks/${assignmentId}`, }, CONTRACTS: { diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 85af9bfdd..cc9f81a29 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; +import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import RoleLicenseStep, { type RoleLicenseProfile, @@ -279,22 +280,39 @@ export default function CompanyProfileForm({ }); }; - /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); - setValue("generalManagerEmail", user.email); - setValue("generalManagerPhone", etradeOwner.phone ?? "", { - shouldValidate: true, - }); - }; - // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); const [poaSameAsContact, setPoaSameAsContact] = useState(false); + // General Manager source: the eTrade-registered business owner when a TIN + // lookup found one, otherwise the registering user's own account details. + const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourcePhone = etradeOwner + ? etradeOwner.phone + : toEthiopianE164(user.phoneNumber); + + useEffect(() => { + if (!gmSameAsOwner) return; + setValue("generalManagerName", gmSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", gmSourcePhone ?? "", { + shouldValidate: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]); + + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + }; + const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); @@ -341,6 +359,72 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Hard verification for the documents step: required company-level + // documents and a business license per operational profile must both be + // present before the user can continue. + const [documentFieldErrors, setDocumentFieldErrors] = useState< + Record + >({}); + const [licenseFieldErrors, setLicenseFieldErrors] = useState< + Record + >({}); + + const validateRequiredDocuments = (): Record => { + const errs: Record = {}; + for (const field of uploadSetting?.fields ?? []) { + const min = getMinFiles(field); + if (min <= 0) continue; + if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; + const v = documentFiles[field.fileKey]; + const count = Array.isArray(v) ? v.length : v ? 1 : 0; + if (count < min) { + errs[field.fileKey] = `${field.fileLabel} is required`; + } + } + return errs; + }; + + // Every role needs at least one license file (existing or newly selected). + const validateLicenses = (): Record => { + const errs: Record = {}; + for (const p of roleProfiles ?? []) { + const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; + const hasExisting = p.existingFiles.length > 0; + if (!hasNew && !hasExisting) { + errs[p.id] = "Business license is required"; + } + } + return errs; + }; + + const handleDocumentFilesChange = ( + next: Record, + ) => { + setDocumentFiles(next); + setDocumentFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + const v = next[key]; + const hasValue = Array.isArray(v) ? v.length > 0 : v != null; + if (hasValue) delete updated[key]; + } + return updated; + }); + }; + + const handleLicenseFilesChange = (next: Record) => { + onLicenseChange?.(next); + setLicenseFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const id of Object.keys(updated)) { + if ((next[id]?.length ?? 0) > 0) delete updated[id]; + } + return updated; + }); + }; + // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup // (or rehydration) has filled them in. The address fields below are separate: @@ -385,18 +469,21 @@ export default function CompanyProfileForm({ } }; - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - const nextStep = async () => { userNavigatedRef.current = true; - // The documents step auto-uploads whatever the user selected as they - // continue (partial uploads are allowed — required-doc completeness is - // re-checked on resume). A failed upload holds them on the step. + // The documents step hard-blocks on required company documents and a + // business license per operational profile before it auto-uploads and + // submits — no partial-completion path forward. if (step === "documents") { + const docErrors = validateRequiredDocuments(); + const licenseErrors = validateLicenses(); + if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) { + setDocumentFieldErrors(docErrors); + setLicenseFieldErrors(licenseErrors); + setSaveError("Please upload all required documents before continuing."); + return; + } + if (onUploadDocuments) { setSaving(true); try { @@ -410,12 +497,6 @@ export default function CompanyProfileForm({ } } - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } setSaveError(null); handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; @@ -450,8 +531,6 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} /> - - - + - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + )} { })} + onChange={handleLicenseFilesChange} + errors={licenseFieldErrors} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index bdd10871b..885888a05 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -24,7 +26,6 @@ export default function LoginPage() { const { login } = useAuth(); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +42,8 @@ export default function LoginPage() { } else { setError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setError(extractApiError(err).message); } finally { setLoading(false); } @@ -64,60 +65,45 @@ export default function LoginPage() {

-
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" + + setIdentifier(event.target.value)} + /> + +
+
+ Password + + Forgot password? + +
+ setPassword(event.target.value)} />
-
-
- - - Forgot password? - -
-
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
-
- {error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +115,7 @@ export default function LoginPage() { Create an account

-
+ ); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 50320f699..1b13f7cfc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -34,14 +34,15 @@ import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; -const EDR_LOGO = "/assets/edr-logo.png"; - const passwordRequirements = [ { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, ] as const; const userSchema = z @@ -52,8 +53,14 @@ const userSchema = z .min(1, "Phone number is required") .refine(isValidPhone, "Enter a valid phone number"), userType: z.string(), - firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), - lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), + firstName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + lastName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), password: z .string() .min(8, "Password must be at least 8 characters") @@ -132,12 +139,30 @@ export default function SignupPage() { const passwordValue = watch("password") ?? ""; - // Step 1 — form is valid: send a fresh code to the chosen channel, then - // move to the OTP challenge. + // Step 1 — form is valid: make sure the email/phone aren't already + // registered, then send a fresh code to the chosen channel and move to + // the OTP challenge. const requestOtp = async (data: FormData) => { setError(null); setSending(true); try { + const availability = await api.auth.checkAvailability.call({ + email: data.email, + phone: data.phone, + }); + if (availability.emailTaken && availability.phoneTaken) { + setError("An account with this email and phone number already exists."); + return; + } + if (availability.emailTaken) { + setError("An account with this email already exists."); + return; + } + if (availability.phoneTaken) { + setError("An account with this phone number already exists."); + return; + } + await api.auth.sendOTP.call( channel === "email" ? { email: data.email } : { phone: data.phone }, ); @@ -217,242 +242,270 @@ export default function SignupPage() { return ( -
-
- EDR Freight -
- - {stage === "form" ? ( -
-
-

- Create account -

-

- Register to access EDR Freight services. +

+ { stage === "form" ? ( + +
+

+ Create account +

+ < p className = "text-sm leading-relaxed text-gray-500" > + Register to access EDR Freight services.

-
+
- - - + + - - + - - + < ControlledPhoneField + control = { control } + name = "phone" + label = "Phone" + required + disabled = { sending } + /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - +
+ + Send verification code via + + < SegmentedControl + fullWidth + disabled = { sending } + value = { channel } + onChange = {(v) => setChannel(v as OtpChannel) +} +data = { + [ + { + value: "phone", + label: ( + + Phone + ), - }, - { - value: "email", - label: ( - - Email - +}, +{ + value: "email", + label: ( + + Email + ), }, ]} /> -
+
-
- + - {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- 0 ? ( +
+ { + passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( +
+ - {met ? : } - - - {req.label} - -
+ { + met?( + + ): ( + + ) + } + + < span + className = {`text-xs ${met ? "text-primary" : "text-gray-500"}` +} + > + { req.label } + +
); })} -
+
) : null} -
+
- - {error ? ( - }> - {error} - +{ + error ? ( + } + > + { error } + ) : null} - + Continue + -

- Already have an account?{" "} - -

- - + < p className = "text-center text-sm text-gray-500" > + Already have an account ? { " "} + < button + type = "button" +onClick = {() => navigate("/login")} +className = "font-semibold text-primary hover:underline" + > + Sign In + +

+ + ) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6-digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - . Enter it to finish creating your account. + +

+ + + +
+ < div className = "space-y-1.5 text-center" > +

+ Verify your { otpChannel === "email" ? "email" : "phone" } +

+ < p className = "text-sm leading-relaxed text-gray-500" > + We sent a 6 - digit code to{ " " } + + { otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + .Enter it to finish creating your account.

-
+
- {otpError ? ( - }> - {otpError} - +{ + otpError ? ( + } + > + { otpError } + ) : null} - - - Verification code - - - + + + Verification code + + < PinInput +length = { 6} +type = "number" +oneTimeCode +value = { otpCode } +placeholder = "0" +disabled = { verifying } +styles = {{ input: { textAlign: "center" } }} +onChange = { setOtpCode } + /> + - + < Button +color = "edr-green" +fullWidth +loading = { verifying } +disabled = { verifying || otpCode.trim().length !== 6} +onClick = { confirmOtp } + > + Verify & amp; create account + -
- - -
- + Back + + < Button +variant = "subtle" +color = "edr-green" +leftSection = {< RotateCw size = { 14} />} +disabled = { resendIn > 0 || sending || verifying} +onClick = { resendOtp } + > + { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + +
+ )} -
- +
+ ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index c6ec6bf5d..65af584d3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -1,15 +1,30 @@ -import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { + ActionIcon, + Alert, + Badge, + Button, + Divider, + Group, + Loader, + MultiSelect, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { Download, Lock, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react"; import { useState } from "react"; +import toast from "react-hot-toast"; import { api } from "@/services/api"; +import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; -const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => { URL.revokeObjectURL(url); }; +const errorMessage = (error: unknown, fallback: string) => { + const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : fallback; +}; + export function CustomerTruckAssignmentCard({ booking, onAssigned, @@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({ booking: Freight.IBooking; onAssigned: () => void; }) { - const assigned = Boolean(booking.customerTruckAssignedAt); - const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? ""); - const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? ""); - const [truckType, setTruckType] = useState(booking.customerTruckType ?? ""); - const [containerNumberToLoad, setContainerNumberToLoad] = useState( - booking.customerTruckContainerNumber ?? "", - ); + const queryClient = useQueryClient(); + const trucksKey = ["customer-trucks", booking.id]; + + const { data: trucks = [], isLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => customerTrucksService.list(booking.id), + }); + + const [plateNumber, setPlateNumber] = useState(""); + const [driverName, setDriverName] = useState(""); + const [truckType, setTruckType] = useState(""); + const [containers, setContainers] = useState([]); const [error, setError] = useState(null); - // Physical container numbers on this booking — the customer picks which one to - // load onto the truck instead of typing it. Falls back to free entry when the - // booking has no container numbers recorded. - const containerOptions = booking.containerNumbers ?? []; + // Container numbers on the booking that aren't already loaded onto a truck. + const assignedNumbers = new Set( + trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), + ); + const availableContainers = (booking.containerNumbers ?? []).filter( + (n) => !assignedNumbers.has(n), + ); - const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); - const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); + // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't — + // staff register + weigh what was loaded when the truck leaves. + const isExport = booking.tradeDirection === "EXPORT"; - const submit = async () => { - const payload = { - truckPlateNumber: truckPlateNumber.trim().toUpperCase(), - driverName: driverName.trim(), - truckType: truckType.trim(), - containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(), - }; - if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) { - setError("All truck assignment fields are required."); - return; - } - if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) { - setError("Container number must match ISO format, e.g. ABCD1234567."); - return; - } + const resetForm = () => { + setPlateNumber(""); + setDriverName(""); + setTruckType(""); + setContainers([]); setError(null); - await assignMutation.mutateAsync({ id: booking.id, payload }); - onAssigned(); }; + const addMutation = useMutation({ + mutationFn: () => + customerTrucksService.add(booking.id, { + truckPlateNumber: plateNumber.trim().toUpperCase(), + driverName: driverName.trim(), + truckType: truckType.trim(), + // Import: containers are registered + weighed on departure, not here. + containerNumbers: isExport ? containers : [], + }), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + resetForm(); + onAssigned(); + toast.success("Truck added"); + }, + onError: (e) => setError(errorMessage(e, "Could not add truck")), + }); + + const removeMutation = useMutation({ + mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + onAssigned(); + }, + onError: (e) => toast.error(errorMessage(e, "Could not remove truck")), + }); + + const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); const downloadFreightOrder = async () => { const blob = await downloadMutation.mutateAsync({ id: booking.id }); downloadBlob(blob, `freight-order-${booking.reference}.pdf`); }; + const submitAdd = () => { + if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) { + setError("Plate number, driver name and truck type are required."); + return; + } + if (isExport && (containers.length < 1 || containers.length > 2)) { + setError("Select 1 or 2 container numbers for this truck."); + return; + } + setError(null); + addMutation.mutate(); + }; + return ( @@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment - {assigned && ( - - - - Truck Assigned - - + {trucks.length > 0 && ( + + {trucks.length} truck{trucks.length !== 1 ? "s" : ""} + )} + {/* Assigned trucks */} + {isLoading ? ( + + + + ) : ( + trucks.map((t) => ( + + + + + {t.plateNumber} + + {t.arrivedAt ? ( + }> + Arrived + + ) : ( + }> + Awaiting arrival + + )} + + + {t.driverName} · {t.truckType} + + + {(t.containers ?? []).map((c) => ( + + {c.containerNumber} + + ))} + + + {!t.arrivedAt && ( + removeMutation.mutate(t.id)} + loading={removeMutation.isPending} + > + + + )} + + )) + )} + {error && ( {error} )} - {assignMutation.isError && ( - - {assignMutation.error instanceof Error - ? assignMutation.error.message - : "Truck assignment failed."} - + + {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} + {(isExport ? availableContainers.length > 0 : true) ? ( + <> + + + setPlateNumber(e.currentTarget.value.toUpperCase())} + /> + setDriverName(e.currentTarget.value)} + /> + setTruckType(value ?? "")} - disabled={assigned} - /> - {containerOptions.length > 0 ? ( - setForm({ ...form, os: e.target.value })}> + + + +
+
+ + setForm({ ...form, version: e.target.value })} /> +
+
+ +
+ +
+ {(['true', 'false'] as const).map((val) => ( + + ))} +
+
+ +
+ + setForm({ ...form, storeLink: e.target.value })} /> +
+ +
+ +