mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
18
.github/workflows/deploy.yml
vendored
18
.github/workflows/deploy.yml
vendored
@@ -182,24 +182,6 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
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
|
- name: Remove npm credentials from workspace
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f .npmrc .npmrc_temp
|
run: rm -f .npmrc .npmrc_temp
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
DROP COLUMN IF EXISTS gross_weight_kg,
|
||||||
|
DROP COLUMN IF EXISTS departed_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { FreightMeController } from './freight-me.controller';
|
||||||
import { FreightMeService } from './freight-me.service';
|
import { FreightMeService } from './freight-me.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [FreightMeController],
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
providers: [FreightMeService],
|
controllers: [FreightMeController, CheckAvailabilityController],
|
||||||
|
providers: [FreightMeService, CheckAvailabilityService],
|
||||||
})
|
})
|
||||||
export class FreightAuthModule {}
|
export class FreightAuthModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
@@ -61,6 +62,11 @@ import {
|
|||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.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 { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +89,8 @@ export class BookingsController {
|
|||||||
private readonly transitionService: BookingTransitionService,
|
private readonly transitionService: BookingTransitionService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
|
private readonly customerTruckService: CustomerTruckService,
|
||||||
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -309,6 +317,94 @@ export class BookingsController {
|
|||||||
res.send(buffer);
|
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')
|
@Get(':id/tracking')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Shipment tracking timeline for a booking",
|
summary: "Shipment tracking timeline for a booking",
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.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 { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||||
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
BookingContainerAllocation,
|
BookingContainerAllocation,
|
||||||
|
CustomerTruckAssignment,
|
||||||
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
|
CustomerTruckAssignmentsRepository,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BookingsModule { }
|
export class BookingsModule { }
|
||||||
|
|||||||
@@ -145,7 +145,28 @@ export class BookingsService {
|
|||||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
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);
|
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||||
return {
|
return {
|
||||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
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')}`;
|
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
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
? 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],
|
['Booking Reference', booking.reference],
|
||||||
['Client Name', booking.company?.name],
|
['Client Name', booking.company?.name],
|
||||||
['Client ID', booking.companyId],
|
['Client ID', booking.companyId],
|
||||||
['Trade Direction', booking.tradeDirection],
|
['Trade Direction', booking.tradeDirection],
|
||||||
['Freight Type', booking.freightType],
|
['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],
|
['Assigned At', assignedAt],
|
||||||
['Booking Status', booking.status],
|
['Booking Status', booking.status],
|
||||||
];
|
];
|
||||||
const rowHtml = rows
|
const bookingRowHtml = bookingRows
|
||||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||||
.join('');
|
.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]) =>
|
||||||
|
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const copy = (watermark: string) => `
|
const copy = (watermark: string) => `
|
||||||
<section class="copy">
|
<section class="copy">
|
||||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>Freight Order</h1>
|
<h1>Freight Order</h1>
|
||||||
<p>Customer external truck assignment</p>
|
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||||
</header>
|
</header>
|
||||||
<table>${rowHtml}</table>
|
<table>${bookingRowHtml}</table>
|
||||||
|
${truckBlocks}
|
||||||
<div class="signatures">
|
<div class="signatures">
|
||||||
<div>Customer / Carrier Signature</div>
|
<div>Customer / Carrier Signature</div>
|
||||||
<div>Port Operations Verification</div>
|
<div>Port Operations Verification</div>
|
||||||
@@ -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; }
|
.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; }
|
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; }
|
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; }
|
p { margin: 4px 0 0; color: #64748b; }
|
||||||
strong { font-size: 16px; color: #0a9f6a; }
|
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, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||||
th { width: 34%; background: #f1f5f9; }
|
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 { 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; }
|
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<ReceivedUnitRow[]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerTruckAssignment> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CustomerTruckAssignment)
|
||||||
|
private readonly repo: Repository<CustomerTruckAssignment>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All trucks assigned to a booking, oldest first, with their containers. */
|
||||||
|
findByBookingId(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
relations: { containers: true },
|
||||||
|
order: { assignedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithContainers(id: string): Promise<CustomerTruckAssignment | null> {
|
||||||
|
return this.repo.findOne({ where: { id }, relations: { containers: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerTruckAssignment[]> {
|
||||||
|
return this.assignments.findByBookingId(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
||||||
|
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<CustomerTruckAssignment[]> {
|
||||||
|
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<CustomerTruckAssignment[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<BookingGuardRow> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||||
sortOrder!: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1183,9 +1183,11 @@ export class CompaniesService {
|
|||||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||||
if (!businessInfo) {
|
if (!businessInfo) {
|
||||||
throw new BadRequestException(
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10)
|
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||||
@Matches(/^00\d{8}$/, {
|
|
||||||
message: 'TIN must be 10 digits starting with 00',
|
|
||||||
})
|
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
managerName!: string;
|
managerName!: string;
|
||||||
managerEmail?: string;
|
managerEmail?: string;
|
||||||
managerPhone!: string;
|
managerPhone!: string;
|
||||||
|
tinTaken?: boolean;
|
||||||
|
|
||||||
constructor(data: CompanyRegistrationData) {
|
constructor(data: CompanyRegistrationData) {
|
||||||
this.licenceNumber = data.licenceNumber;
|
this.licenceNumber = data.licenceNumber;
|
||||||
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
this.managerName = data.managerName;
|
this.managerName = data.managerName;
|
||||||
this.managerEmail = data.managerEmail;
|
this.managerEmail = data.managerEmail;
|
||||||
this.managerPhone = data.managerPhone;
|
this.managerPhone = data.managerPhone;
|
||||||
|
this.tinTaken = data.tinTaken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(10, 10)
|
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||||
@Matches(/^00\d{8}$/, {
|
|
||||||
message: 'TIN must be 10 digits starting with 00',
|
|
||||||
})
|
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1567,9 +1567,7 @@ export class TrainSchedulingService {
|
|||||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||||
this.assertImportDjiboutiGatepassGranted(operation);
|
this.assertImportDjiboutiGatepassGranted(operation);
|
||||||
if (!operation.loadedOnTrainAt) {
|
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
|
||||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||||
await this.dispatchSchedule(schedule.id);
|
await this.dispatchSchedule(schedule.id);
|
||||||
@@ -1946,9 +1944,9 @@ export class TrainSchedulingService {
|
|||||||
where: { trainScheduleId: schedule.id },
|
where: { trainScheduleId: schedule.id },
|
||||||
});
|
});
|
||||||
this.assertImportDjiboutiGatepassGranted(operation);
|
this.assertImportDjiboutiGatepassGranted(operation);
|
||||||
if (!operation?.loadedOnTrainAt) {
|
// Loading confirmation does NOT gate dispatch. Per-booking loading is
|
||||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
// tracking only and the loaded-on-train step is optional — a scheduled train
|
||||||
}
|
// dispatches without waiting on loading.
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||||
|
|||||||
@@ -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(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
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(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
|
|||||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||||
});
|
});
|
||||||
if (!isTruckLeaving && item.bookingId) {
|
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(
|
await manager.query(
|
||||||
`UPDATE freight.bookings
|
`UPDATE freight.bookings
|
||||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||||
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
await this.invoices.assertClearanceAllowed(id);
|
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 bookingReference = row?.bookingReference || 'N/A';
|
||||||
const reference =
|
const reference =
|
||||||
row?.releaseOrderReference ||
|
row?.releaseOrderReference ||
|
||||||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
|
|||||||
inventoryStatus: row?.status ?? null,
|
inventoryStatus: row?.status ?? null,
|
||||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
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 {
|
return {
|
||||||
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
|
|||||||
inventoryStatus: string | null;
|
inventoryStatus: string | null;
|
||||||
clearanceStatus: string;
|
clearanceStatus: string;
|
||||||
exitInspectionSummary?: string | null;
|
exitInspectionSummary?: string | null;
|
||||||
|
truckPlateNumber?: string | null;
|
||||||
|
truckDriverName?: string | null;
|
||||||
|
truckType?: string | null;
|
||||||
|
truckGateOut?: string | null;
|
||||||
|
truckWeightKg?: number | null;
|
||||||
}): string {
|
}): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
|
|||||||
['Container Number', data.containerNumber],
|
['Container Number', data.containerNumber],
|
||||||
['Cargo / Goods Description', data.cargoDescription],
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
['Quantity', data.quantity],
|
['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],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
['Zone', data.zone],
|
['Zone', data.zone],
|
||||||
['Inventory Status', data.inventoryStatus],
|
['Inventory Status', data.inventoryStatus],
|
||||||
['Clearance Status', data.clearanceStatus],
|
['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]] : []),
|
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -23,6 +23,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "User Management",
|
label: "Staff",
|
||||||
href: "/um",
|
href: "/um",
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Booking requests",
|
label: "Bookings",
|
||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contract requests",
|
label: "Contracts",
|
||||||
href: "/dashboard/contract-requests",
|
href: "/dashboard/contract-requests",
|
||||||
icon: <FileSignature />,
|
icon: <FileSignature />,
|
||||||
permission: FREIGHT_PERMS.contracts.view,
|
permission: FREIGHT_PERMS.contracts.view,
|
||||||
@@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
title: "Operations",
|
title: "Operations",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Document Clearance",
|
label: "Clearance",
|
||||||
href: "/dashboard/contracts/clearance",
|
href: "/dashboard/contracts/clearance",
|
||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: [
|
permission: [
|
||||||
@@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
title: "Port & Terminal",
|
title: "Port & Terminal",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Import Operations",
|
label: "Imports",
|
||||||
href: "/dashboard/import-warehouse",
|
href: "/dashboard/import-warehouse",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
children: [
|
children: [
|
||||||
@@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Export Operations",
|
label: "Exports",
|
||||||
href: "/dashboard/export-warehouse",
|
href: "/dashboard/export-warehouse",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
children: [
|
children: [
|
||||||
@@ -516,6 +517,38 @@ const filterSidebarByPermission = (
|
|||||||
.filter((section) => section.items.length > 0);
|
.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 DashboardShell = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -541,6 +574,14 @@ const DashboardShell = () => {
|
|||||||
: null
|
: null
|
||||||
: 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)) {
|
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||||
return <Navigate to={glClearanceHome} replace />;
|
return <Navigate to={glClearanceHome} replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||||
|
<Box
|
||||||
|
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||||
|
style={{ background: LEFT_PANEL_BG }}
|
||||||
|
>
|
||||||
|
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||||
|
<Stack
|
||||||
|
gap="xl"
|
||||||
|
className="relative z-10 p-8 lg:p-10"
|
||||||
|
style={{ maxWidth: 520 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={EDR_LOGO}
|
||||||
|
alt="EDR Freight"
|
||||||
|
h={40}
|
||||||
|
w="auto"
|
||||||
|
fit="contain"
|
||||||
|
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title
|
||||||
|
order={1}
|
||||||
|
c="white"
|
||||||
|
fz={38}
|
||||||
|
fw={800}
|
||||||
|
lh={1.08}
|
||||||
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||||
|
</Title>
|
||||||
|
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||||
|
{taglineBody ??
|
||||||
|
"Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||||
|
<Image
|
||||||
|
src={EDR_IMAGE}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
fit="cover"
|
||||||
|
pos="absolute"
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
w="80%"
|
||||||
|
h="60%"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||||
|
maskImage: IMAGE_FADE_MASK,
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.9,
|
||||||
|
maskComposite: "intersect",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RightPanelDecor = () => (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 h-full w-full text-gray-200/40"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id="auth-grid"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LanguageSelector = () => (
|
||||||
|
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||||
|
<Globe className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>Eng</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AuthShell({
|
||||||
|
children,
|
||||||
|
tagline,
|
||||||
|
taglineBody,
|
||||||
|
}: AuthShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]">
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||||
|
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-white shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<RightPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<div className="flex min-h-full justify-center">
|
||||||
|
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { SidebarItem, SidebarSection } from "./types";
|
import type { SidebarItem, SidebarSection } from "./types";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export interface FreightSidebarProps {
|
export interface FreightSidebarProps {
|
||||||
sections: SidebarSection[];
|
sections: SidebarSection[];
|
||||||
@@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg";
|
|||||||
const navClassNames = (active: boolean) =>
|
const navClassNames = (active: boolean) =>
|
||||||
active
|
active
|
||||||
? {
|
? {
|
||||||
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
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!",
|
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||||
section: "text-edr-primary-dark!",
|
section: "text-edr-primary-dark!",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
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!",
|
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||||
section: "text-edr-text!",
|
section: "text-edr-text!",
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
||||||
`${parentKey}/${item.href ?? item.label}/${index}`;
|
`${parentKey}/${item.href ?? item.label}/${index}`;
|
||||||
@@ -65,7 +66,9 @@ const FreightSidebar = ({
|
|||||||
const isHrefActive = useCallback(
|
const isHrefActive = useCallback(
|
||||||
(href: string) => {
|
(href: string) => {
|
||||||
const normalized = href.toLowerCase();
|
const normalized = href.toLowerCase();
|
||||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
return (
|
||||||
|
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[activePath],
|
[activePath],
|
||||||
);
|
);
|
||||||
@@ -109,9 +112,7 @@ const FreightSidebar = ({
|
|||||||
|
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
const isLink = !!item.href;
|
const isLink = !!item.href;
|
||||||
const active =
|
const active = isLink ? isHrefActive(item.href!) : false;
|
||||||
(isLink ? isHrefActive(item.href!) : false) ||
|
|
||||||
branchActive(item.children!);
|
|
||||||
const isOpen = openMap[key] ?? false;
|
const isOpen = openMap[key] ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +125,7 @@ const FreightSidebar = ({
|
|||||||
active={active}
|
active={active}
|
||||||
opened={isOpen}
|
opened={isOpen}
|
||||||
classNames={navClassNames(active)}
|
classNames={navClassNames(active)}
|
||||||
onClick={ () => toggle(key)}
|
onClick={() => toggle(key)}
|
||||||
rightSection={
|
rightSection={
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
@@ -140,7 +141,9 @@ const FreightSidebar = ({
|
|||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className="text-edr-muted transition-transform duration-200"
|
className="text-edr-muted transition-transform duration-200"
|
||||||
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
|
style={{
|
||||||
|
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -161,8 +164,9 @@ const FreightSidebar = ({
|
|||||||
label={item.label}
|
label={item.label}
|
||||||
leftSection={item.icon}
|
leftSection={item.icon}
|
||||||
active={active}
|
active={active}
|
||||||
|
component={Link}
|
||||||
classNames={navClassNames(active)}
|
classNames={navClassNames(active)}
|
||||||
onClick={() => onNavigate?.(item.href!)}
|
to={item.href!}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -178,7 +182,7 @@ const FreightSidebar = ({
|
|||||||
tt="uppercase"
|
tt="uppercase"
|
||||||
px="sm"
|
px="sm"
|
||||||
mb={6}
|
mb={6}
|
||||||
className={ "text-edr-muted!" }
|
className={"text-edr-muted!"}
|
||||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||||
>
|
>
|
||||||
{section.title}
|
{section.title}
|
||||||
@@ -232,14 +236,24 @@ const FreightSidebar = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
|
<UnstyledButton
|
||||||
|
onClick={onClose}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
>
|
||||||
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
|
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
|
<AppShell.Section
|
||||||
|
grow
|
||||||
|
component={ScrollArea}
|
||||||
|
type="never"
|
||||||
|
px="sm"
|
||||||
|
pb="md"
|
||||||
|
>
|
||||||
<Stack gap="lg">{renderedSections}</Stack>
|
<Stack gap="lg">{renderedSections}</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,162 +1,40 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Eye,
|
Alert,
|
||||||
EyeOff,
|
Box,
|
||||||
ArrowUpRight,
|
Button,
|
||||||
Globe,
|
Center,
|
||||||
ChevronDown,
|
Group,
|
||||||
} from "lucide-react";
|
Image,
|
||||||
|
PasswordInput,
|
||||||
|
PinInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
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. */
|
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||||
const normaliseIdentifier = (raw: string): string => {
|
const normaliseIdentifier = (raw: string): string => {
|
||||||
const v = raw.trim();
|
const v = raw.trim();
|
||||||
const digits = v.replace(/\D/g, "");
|
const digits = v.replace(/\D/g, "");
|
||||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
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 `+251${local}`;
|
||||||
}
|
}
|
||||||
return v.toLowerCase();
|
return v.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_IMAGE = "/assets/login.png";
|
|
||||||
const EDR_LOGO = "/assets/logo.svg";
|
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 = () => (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
|
||||||
viewBox="0 0 400 400"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
|
||||||
<circle
|
|
||||||
key={ring}
|
|
||||||
cx="200"
|
|
||||||
cy="200"
|
|
||||||
r={60 + ring * 36}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RightPanelDecor = () => (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
|
||||||
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
|
||||||
<svg
|
|
||||||
className="absolute inset-0 h-full w-full text-gray-200/40"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<pattern
|
|
||||||
id="login-grid"
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
patternUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#login-grid)" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LeftPanel = () => (
|
|
||||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
|
||||||
<img
|
|
||||||
src={LOGIN_IMAGE}
|
|
||||||
alt="Ethio Djibouti Railway"
|
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
|
||||||
<LeftPanelDecor />
|
|
||||||
|
|
||||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
|
||||||
<img
|
|
||||||
src={EDR_LOGO}
|
|
||||||
alt="EDR Freight"
|
|
||||||
className="h-7 w-auto brightness-0 invert sm:h-9"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
|
||||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
|
||||||
<span className="text-sm font-semibold text-white">
|
|
||||||
Empower Your Freight Operations
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed text-white/85">
|
|
||||||
Sign in to manage bookings, track cargo, and run logistics operations
|
|
||||||
on the Ethio Djibouti Railway freight platform.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LanguageSelector = () => (
|
|
||||||
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
|
||||||
<Globe className="h-4 w-4 text-gray-500" />
|
|
||||||
<span>Eng</span>
|
|
||||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FormFooter = () => (
|
|
||||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
|
||||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Terms & Conditions
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Help & Support
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login, verifyMfa } = useAuth();
|
const { login, verifyMfa } = useAuth();
|
||||||
@@ -165,7 +43,6 @@ const LoginPage = () => {
|
|||||||
const [otp, setOtp] = useState("");
|
const [otp, setOtp] = useState("");
|
||||||
const [needsMfa, setNeedsMfa] = useState(false);
|
const [needsMfa, setNeedsMfa] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -179,15 +56,14 @@ const LoginPage = () => {
|
|||||||
setNormalizedIdentifier(normalized);
|
setNormalizedIdentifier(normalized);
|
||||||
|
|
||||||
const result = await login({ email: normalized, password });
|
const result = await login({ email: normalized, password });
|
||||||
console.log(result);
|
|
||||||
if (result.mfaRequired) {
|
if (result.mfaRequired) {
|
||||||
setNeedsMfa(true);
|
setNeedsMfa(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// navigate("/dashboard/overview", { replace: true });
|
// navigate("/dashboard/overview", { replace: true });
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("Unable to sign in with those credentials.");
|
setError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -201,194 +77,132 @@ const LoginPage = () => {
|
|||||||
try {
|
try {
|
||||||
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
||||||
navigate("/dashboard/overview", { replace: true });
|
navigate("/dashboard/overview", { replace: true });
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("Unable to verify the one-time code.");
|
setError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginForm = (
|
const loginForm = (
|
||||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
<div className="mb-4 flex justify-center sm:mb-6">
|
<Center mb={{ base: "md", sm: "lg" }}>
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||||
</div>
|
</Center>
|
||||||
|
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||||
Get Started
|
Welcome back!
|
||||||
</h1>
|
</Title>
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
<Text size="sm" c="dimmed">
|
||||||
Log in to access the freight backoffice & explore all logistics
|
Log in to access the freight backoffice & explore all logistics
|
||||||
resources.
|
resources.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
<Stack gap="md">
|
||||||
<div className="space-y-1.5">
|
<TextInput
|
||||||
<label className="text-sm font-medium text-gray-800">
|
label="Email or Phone"
|
||||||
Email or Phone <span className="text-red-500">*</span>
|
placeholder="name@company.com or 09XXXXXXXX"
|
||||||
</label>
|
autoComplete="username"
|
||||||
<input
|
required
|
||||||
type="text"
|
disabled={submitting}
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
placeholder="name@company.com or 09XXXXXXXX"
|
/>
|
||||||
autoComplete="username"
|
|
||||||
className={fieldClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<PasswordInput
|
||||||
<label className="text-sm font-medium text-gray-800">
|
label="Password"
|
||||||
Password <span className="text-red-500">*</span>
|
placeholder="Enter your password"
|
||||||
</label>
|
required
|
||||||
<div className="relative">
|
disabled={submitting}
|
||||||
<input
|
value={password}
|
||||||
type={showPassword ? "text" : "password"}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
value={password}
|
/>
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
className={`${fieldClass} pr-11`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((current) => !current)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<Button type="submit" color="edr-green" fullWidth loading={submitting}>
|
||||||
type="submit"
|
Sign In
|
||||||
disabled={submitting}
|
</Button>
|
||||||
className={primaryButtonClass}
|
</Stack>
|
||||||
>
|
</Box>
|
||||||
{submitting ? "Signing in..." : "Sign In"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
|
||||||
Need an account?{" "}
|
|
||||||
<a href="#" className="font-semibold text-primary hover:underline">
|
|
||||||
Contact your admin
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const mfaForm = (
|
const mfaForm = (
|
||||||
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}>
|
<Box component="form" onSubmit={handleVerifyMfa}>
|
||||||
<div className="mb-4 flex justify-center sm:mb-6">
|
<Center mb={{ base: "md", sm: "lg" }}>
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||||
</div>
|
</Center>
|
||||||
|
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||||
Multi-factor verification
|
Multi-factor verification
|
||||||
</h1>
|
</Title>
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
<Text size="sm" c="dimmed">
|
||||||
We sent a verification code to{" "}
|
We sent a verification code to{" "}
|
||||||
<span className="font-medium text-gray-700">
|
<Text component="span" fw={500} c="var(--mantine-color-text)">
|
||||||
{normalizedIdentifier}
|
{normalizedIdentifier}
|
||||||
</span>
|
</Text>
|
||||||
. Enter it below to complete sign in.
|
. Enter it below to complete sign in.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
<Stack gap="md">
|
||||||
<div className="space-y-1.5">
|
<Stack gap={6} align="center">
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
Verification code <span className="text-red-500">*</span>
|
Verification code
|
||||||
</label>
|
</Text>
|
||||||
<input
|
<PinInput
|
||||||
|
length={6}
|
||||||
|
type="number"
|
||||||
|
oneTimeCode
|
||||||
value={otp}
|
value={otp}
|
||||||
onChange={(event) => setOtp(event.target.value)}
|
placeholder="0"
|
||||||
placeholder="Enter the code"
|
disabled={submitting}
|
||||||
className={fieldClass}
|
styles={{ input: { textAlign: "center" } }}
|
||||||
|
onChange={setOtp}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex w-full gap-3">
|
<Group grow>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ArrowLeft size={14} />}
|
||||||
|
disabled={submitting}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNeedsMfa(false);
|
setNeedsMfa(false);
|
||||||
setOtp("");
|
setOtp("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="h-11 min-w-0 flex-1 rounded-full border border-gray-200 bg-white text-sm font-semibold text-gray-700 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
color="edr-green"
|
||||||
className={`${primaryButtonClass} min-w-0 flex-1`}
|
loading={submitting}
|
||||||
|
disabled={otp.trim().length !== 6}
|
||||||
>
|
>
|
||||||
{submitting ? "Verifying..." : "Verify"}
|
Verify
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
</Stack>
|
||||||
</form>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <AuthShell>{!needsMfa ? loginForm : mfaForm}</AuthShell>;
|
||||||
<>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
|
||||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
|
||||||
>
|
|
||||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
|
||||||
<LeftPanel />
|
|
||||||
|
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
|
||||||
<RightPanelDecor />
|
|
||||||
|
|
||||||
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
|
||||||
<LanguageSelector />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
||||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
|
||||||
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
|
||||||
{!needsMfa ? loginForm : mfaForm}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormFooter />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|||||||
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -1,40 +1,25 @@
|
|||||||
import type { ReactNode } from "react";
|
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";
|
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 =
|
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";
|
"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 =
|
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";
|
"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 = () => (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
|
||||||
viewBox="0 0 400 400"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
|
||||||
<circle
|
|
||||||
key={ring}
|
|
||||||
cx="200"
|
|
||||||
cy="200"
|
|
||||||
r={60 + ring * 36}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RightPanelDecor = () => (
|
const RightPanelDecor = () => (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
@@ -63,7 +48,7 @@ const RightPanelDecor = () => (
|
|||||||
|
|
||||||
export interface AuthShellProps {
|
export interface AuthShellProps {
|
||||||
children: ReactNode;
|
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;
|
tagline?: string;
|
||||||
taglineBody?: string;
|
taglineBody?: string;
|
||||||
}
|
}
|
||||||
@@ -72,45 +57,63 @@ const LeftPanel = ({
|
|||||||
tagline,
|
tagline,
|
||||||
taglineBody,
|
taglineBody,
|
||||||
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl bg-[#011F12] shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
<Box
|
||||||
<img
|
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||||
src={LOGIN_IMAGE}
|
style={{ background: LEFT_PANEL_BG }}
|
||||||
alt="Ethio Djibouti Railway"
|
>
|
||||||
className="absolute inset-0 h-auto w-full object-cover object-center"
|
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||||
/>
|
<Stack
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/15 to-[#1a5c34]/45" />
|
gap="xl"
|
||||||
<LeftPanelDecor />
|
className="relative z-10 p-8 lg:p-10"
|
||||||
|
style={{ maxWidth: 520 }}
|
||||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
>
|
||||||
<img
|
<Image
|
||||||
src={EDR_LOGO}
|
src={EDR_LOGO}
|
||||||
alt="EDR Freight"
|
alt="EDR Freight"
|
||||||
className="h-7 w-auto brightness-0 invert sm:h-9"
|
h={40}
|
||||||
|
w="auto"
|
||||||
|
fit="contain"
|
||||||
|
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||||
/>
|
/>
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
<Stack gap="sm">
|
||||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
<Title
|
||||||
<div className="mb-2 flex items-center gap-2">
|
order={1}
|
||||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
c="white"
|
||||||
<span className="text-sm font-semibold text-white">
|
fz={38}
|
||||||
{tagline ?? "Empower Your Freight Operations"}
|
fw={800}
|
||||||
</span>
|
lh={1.08}
|
||||||
</div>
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
<p className="text-sm leading-relaxed text-white/85">
|
>
|
||||||
|
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||||
|
</Title>
|
||||||
|
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||||
{taglineBody ??
|
{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."}
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
|
||||||
|
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||||
|
<Image
|
||||||
|
src={EDR_IMAGE}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
fit="cover"
|
||||||
|
pos="absolute"
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
w="80%"
|
||||||
|
h="60%"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||||
|
maskImage: IMAGE_FADE_MASK,
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: "none",
|
||||||
|
maskComposite: "intersect",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const LanguageSelector = () => (
|
const LanguageSelector = () => (
|
||||||
@@ -125,24 +128,24 @@ const FormFooter = () => (
|
|||||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||||
<a
|
<Link
|
||||||
href="#"
|
to="#"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Terms & Conditions
|
Terms & Conditions
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
to="#"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
to="#"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Help & Support
|
Help & Support
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -169,8 +172,8 @@ export default function AuthShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
<div className="flex min-h-full justify-center">
|
||||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
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 { useETradeData } from "@/hooks/useETradeData";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
|
|
||||||
interface ETradeInfoProps {
|
interface ETradeInfoProps {
|
||||||
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
|
|||||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidTin = (tin: string) => tin.length === 10;
|
||||||
|
|
||||||
export default function ETradeInfo({
|
export default function ETradeInfo({
|
||||||
tin,
|
tin,
|
||||||
register,
|
register,
|
||||||
@@ -30,53 +34,100 @@ export default function ETradeInfo({
|
|||||||
}: ETradeInfoProps) {
|
}: ETradeInfoProps) {
|
||||||
const mutation = useETradeData();
|
const mutation = useETradeData();
|
||||||
const isLoading = mutation.isPending;
|
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 () => {
|
const handleFetch = async () => {
|
||||||
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
|
if (!isValidTin(tin)) return;
|
||||||
const result = await mutation.mutateAsync(tin);
|
const result = await mutation.mutateAsync(tin);
|
||||||
if (result) {
|
if (result && !result.tinTaken) {
|
||||||
onDataLoaded(result);
|
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<string | null>(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.isError && mutation.error
|
||||||
? (mutation.error as any).message ||
|
? extractApiError(mutation.error)
|
||||||
"Failed to fetch company information. Please try again."
|
: 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;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group align="flex-start" grow>
|
<Group align="flex-start" grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
label={
|
||||||
|
<>
|
||||||
|
TIN Number (10 digits){" "}
|
||||||
|
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
placeholder="0012345678"
|
placeholder="0012345678"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
error={error}
|
error={error}
|
||||||
{...register}
|
{...register}
|
||||||
/>
|
/>
|
||||||
<Button
|
{errorMessage && (
|
||||||
variant="filled"
|
<Button
|
||||||
color="edr-green"
|
variant="filled"
|
||||||
onClick={handleFetch}
|
color="edr-green"
|
||||||
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
|
onClick={handleFetch}
|
||||||
leftSection={
|
disabled={!isValidTin(tin) || isLoading}
|
||||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
leftSection={
|
||||||
}
|
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||||
mt="24px"
|
}
|
||||||
>
|
mt="24px"
|
||||||
{isLoading ? "Getting..." : "Get Data"}
|
>
|
||||||
</Button>
|
{isLoading ? "Getting..." : "Get Data"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{notFound && (
|
||||||
|
<Alert icon={<Info size={16} />} color="gray">
|
||||||
|
We couldn't find a matching business record for this TIN — no
|
||||||
|
problem, just fill in the details below.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert
|
<Alert
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
title="Failed to fetch data"
|
title="Couldn't fetch eTrade data"
|
||||||
>
|
>
|
||||||
{errorMessage} You can still fill in the details manually below.
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tinTaken && (
|
||||||
|
<Alert
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
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.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ interface RoleLicenseStepProps {
|
|||||||
/** Newly-selected files per profile id (not yet uploaded). */
|
/** Newly-selected files per profile id (not yet uploaded). */
|
||||||
value: Record<string, File[]>;
|
value: Record<string, File[]>;
|
||||||
onChange: (value: Record<string, File[]>) => void;
|
onChange: (value: Record<string, File[]>) => void;
|
||||||
|
/** "Business license is required" style error, keyed by profile id. */
|
||||||
|
errors?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +84,7 @@ export default function RoleLicenseStep({
|
|||||||
profiles,
|
profiles,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
errors,
|
||||||
}: RoleLicenseStepProps) {
|
}: RoleLicenseStepProps) {
|
||||||
const setFiles = (profileId: string, files: File[]) => {
|
const setFiles = (profileId: string, files: File[]) => {
|
||||||
onChange({ ...value, [profileId]: files });
|
onChange({ ...value, [profileId]: files });
|
||||||
@@ -123,6 +126,11 @@ export default function RoleLicenseStep({
|
|||||||
file={buildLicenseSetting(profile.id, label)}
|
file={buildLicenseSetting(profile.id, label)}
|
||||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||||
|
errors={
|
||||||
|
errors?.[profile.id]
|
||||||
|
? { [LICENSE_FILE_KEY]: errors[profile.id] }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const next = v[LICENSE_FILE_KEY];
|
const next = v[LICENSE_FILE_KEY];
|
||||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
|
|||||||
SET_PASSWORD: "/api/auth/set-password",
|
SET_PASSWORD: "/api/auth/set-password",
|
||||||
ME: "/api/auth/me",
|
ME: "/api/auth/me",
|
||||||
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
|
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
|
||||||
|
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
||||||
},
|
},
|
||||||
|
|
||||||
OTP: {
|
OTP: {
|
||||||
@@ -106,6 +107,9 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
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: {
|
CONTRACTS: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
|||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import RoleLicenseStep, {
|
import RoleLicenseStep, {
|
||||||
type RoleLicenseProfile,
|
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
|
// "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
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
const [poaSameAsContact, setPoaSameAsContact] = 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 gmName = watch("generalManagerName");
|
||||||
const gmEmail = watch("generalManagerEmail");
|
const gmEmail = watch("generalManagerEmail");
|
||||||
const gmPhone = watch("generalManagerPhone");
|
const gmPhone = watch("generalManagerPhone");
|
||||||
@@ -341,6 +359,72 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
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<string, string>
|
||||||
|
>({});
|
||||||
|
const [licenseFieldErrors, setLicenseFieldErrors] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const validateRequiredDocuments = (): Record<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
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<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
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<string, File | File[] | null>,
|
||||||
|
) => {
|
||||||
|
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<string, File[]>) => {
|
||||||
|
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
|
// 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
|
// 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:
|
// (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 () => {
|
const nextStep = async () => {
|
||||||
userNavigatedRef.current = true;
|
userNavigatedRef.current = true;
|
||||||
// The documents step auto-uploads whatever the user selected as they
|
// The documents step hard-blocks on required company documents and a
|
||||||
// continue (partial uploads are allowed — required-doc completeness is
|
// business license per operational profile before it auto-uploads and
|
||||||
// re-checked on resume). A failed upload holds them on the step.
|
// submits — no partial-completion path forward.
|
||||||
if (step === "documents") {
|
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) {
|
if (onUploadDocuments) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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);
|
setSaveError(null);
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
@@ -450,8 +531,6 @@ export default function CompanyProfileForm({
|
|||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider my="sm" />
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Company Name"
|
label="Company Name"
|
||||||
placeholder="Global Logistics Ltd"
|
placeholder="Global Logistics Ltd"
|
||||||
@@ -507,7 +586,7 @@ export default function CompanyProfileForm({
|
|||||||
from eTrade · read-only
|
from eTrade · read-only
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<ReadOnlyField
|
<ReadOnlyField
|
||||||
label="License Number"
|
label="License Number"
|
||||||
value={watch("licenceNumber")}
|
value={watch("licenceNumber")}
|
||||||
@@ -586,22 +665,19 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{step === "personnel" && (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
General Manager
|
||||||
General Manager
|
</Text>
|
||||||
</Text>
|
<LinkCheckboxCard
|
||||||
{etradeOwner && (
|
checked={gmSameAsOwner}
|
||||||
<Button
|
onToggle={toggleGmSameAsOwner}
|
||||||
variant="light"
|
title="Same as business owner"
|
||||||
color="edr-green"
|
description={
|
||||||
size="xs"
|
etradeOwner
|
||||||
leftSection={<UserCheck size={14} />}
|
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
|
||||||
onClick={useOwnerAsManager}
|
: "Reuse your account's name, email and phone. Uncheck to enter different details."
|
||||||
>
|
}
|
||||||
Use owner as manager
|
/>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="Abebe Bikila"
|
placeholder="Abebe Bikila"
|
||||||
@@ -737,15 +813,17 @@ export default function CompanyProfileForm({
|
|||||||
file={uploadSetting}
|
file={uploadSetting}
|
||||||
value={documentFiles}
|
value={documentFiles}
|
||||||
uploadedKeys={uploadedDocumentKeys}
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
|
errors={documentFieldErrors}
|
||||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||||
onChange={setDocumentFiles}
|
onChange={handleDocumentFilesChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RoleLicenseStep
|
<RoleLicenseStep
|
||||||
profiles={roleProfiles ?? []}
|
profiles={roleProfiles ?? []}
|
||||||
value={licenseFiles ?? {}}
|
value={licenseFiles ?? {}}
|
||||||
onChange={onLicenseChange ?? (() => { })}
|
onChange={handleLicenseFilesChange}
|
||||||
|
errors={licenseFieldErrors}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
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";
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
@@ -24,7 +26,6 @@ export default function LoginPage() {
|
|||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("An unexpected error occurred");
|
setError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -64,60 +65,45 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
<Stack gap="md">
|
||||||
<div className="space-y-1.5">
|
<TextInput
|
||||||
<label className="text-sm font-medium text-gray-800">
|
label="Email or Phone"
|
||||||
Email or Phone <span className="text-red-500">*</span>
|
placeholder="name@company.com or 09XXXXXXXX"
|
||||||
</label>
|
autoComplete="username"
|
||||||
<input
|
required
|
||||||
type="text"
|
disabled={loading}
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
placeholder="name@company.com or 09XXXXXXXX"
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||||
|
<Link
|
||||||
|
to="#"
|
||||||
|
className="text-xs font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<PasswordInput
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="username"
|
value={password}
|
||||||
className={fieldClass}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
|
||||||
Password <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
disabled={loading}
|
|
||||||
className={`${fieldClass} pr-11`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((current) => !current)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
<Button type="submit" color="edr-green" fullWidth loading={loading}>
|
||||||
{loading ? "Signing in..." : "Sign In"}
|
Sign In
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
<p className="text-center text-sm text-gray-500">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
@@ -129,7 +115,7 @@ export default function LoginPage() {
|
|||||||
Create an account
|
Create an account
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,14 +34,15 @@ import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/edr-logo.png";
|
|
||||||
|
|
||||||
const passwordRequirements = [
|
const passwordRequirements = [
|
||||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||||
{ label: "One lowercase 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 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;
|
] as const;
|
||||||
|
|
||||||
const userSchema = z
|
const userSchema = z
|
||||||
@@ -52,8 +53,14 @@ const userSchema = z
|
|||||||
.min(1, "Phone number is required")
|
.min(1, "Phone number is required")
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
userType: z.string(),
|
userType: z.string(),
|
||||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
firstName: z.object({
|
||||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
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
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, "Password must be at least 8 characters")
|
.min(8, "Password must be at least 8 characters")
|
||||||
@@ -132,12 +139,30 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
const passwordValue = watch("password") ?? "";
|
const passwordValue = watch("password") ?? "";
|
||||||
|
|
||||||
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
// Step 1 — form is valid: make sure the email/phone aren't already
|
||||||
// move to the OTP challenge.
|
// registered, then send a fresh code to the chosen channel and move to
|
||||||
|
// the OTP challenge.
|
||||||
const requestOtp = async (data: FormData) => {
|
const requestOtp = async (data: FormData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
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(
|
await api.auth.sendOTP.call(
|
||||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||||
);
|
);
|
||||||
@@ -217,242 +242,270 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Smart Freight Operations"
|
tagline= "Smart Freight Operations"
|
||||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
taglineBody = "Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col" >
|
||||||
<div className="mb-4 flex justify-center sm:mb-6">
|
{ stage === "form" ? (
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
<form
|
||||||
</div>
|
onSubmit= { handleSubmit(requestOtp) }
|
||||||
|
className = "flex w-full flex-col"
|
||||||
{stage === "form" ? (
|
>
|
||||||
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5" >
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
Create account
|
||||||
Create account
|
</h1>
|
||||||
</h1>
|
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
Register to access EDR Freight services.
|
||||||
Register to access EDR Freight services.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack gap="md">
|
< Stack gap = "sm" >
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={ { base: 1, sm: 2 } } spacing = "md" >
|
||||||
<TextInput
|
<TextInput
|
||||||
label="First name"
|
label="First name"
|
||||||
placeholder="John"
|
placeholder = "John"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
error={errors.firstName?.en?.message}
|
error = { errors.firstName?.en?.message }
|
||||||
{...register("firstName.en")}
|
{...register("firstName.en") }
|
||||||
/>
|
/>
|
||||||
<TextInput
|
< TextInput
|
||||||
label="Last name"
|
label = "Last name"
|
||||||
placeholder="Doe"
|
placeholder = "Doe"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
error={errors.lastName?.en?.message}
|
error = { errors.lastName?.en?.message }
|
||||||
{...register("lastName.en")}
|
{...register("lastName.en") }
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<TextInput
|
< TextInput
|
||||||
label="Email"
|
label = "Email"
|
||||||
type="email"
|
type = "email"
|
||||||
placeholder="john@example.com"
|
placeholder = "john@example.com"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
error={errors.email?.message}
|
error = { errors.email?.message }
|
||||||
{...register("email")}
|
{...register("email") }
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledPhoneField
|
< ControlledPhoneField
|
||||||
control={control}
|
control = { control }
|
||||||
name="phone"
|
name = "phone"
|
||||||
label="Phone"
|
label = "Phone"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5" >
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||||
Send verification code via
|
Send verification code via
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
< SegmentedControl
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
value={channel}
|
value = { channel }
|
||||||
onChange={(v) => setChannel(v as OtpChannel)}
|
onChange = {(v) => setChannel(v as OtpChannel)
|
||||||
data={[
|
}
|
||||||
{
|
data = {
|
||||||
value: "phone",
|
[
|
||||||
label: (
|
{
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
value: "phone",
|
||||||
<Smartphone size={14} /> Phone
|
label: (
|
||||||
</span>
|
<span className= "flex items-center justify-center gap-1.5" >
|
||||||
|
<Smartphone size={ 14} /> Phone
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "email",
|
value: "email",
|
||||||
label: (
|
label: (
|
||||||
<span className="flex items-center justify-center gap-1.5">
|
<span className= "flex items-center justify-center gap-1.5" >
|
||||||
<Mail size={14} /> Email
|
<Mail size={ 14 } /> Email
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
< div >
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label="Password"
|
||||||
placeholder="Create a strong password"
|
placeholder = "Create a strong password"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
error={errors.password?.message}
|
error = { errors.password?.message }
|
||||||
{...register("password")}
|
{...register("password") }
|
||||||
/>
|
/>
|
||||||
{passwordValue.length > 0 ? (
|
{
|
||||||
<div className="mt-2 space-y-1">
|
passwordValue.length > 0 ? (
|
||||||
{passwordRequirements.map((req) => {
|
<div className= "mt-2 space-y-1" >
|
||||||
const met = req.test(passwordValue);
|
{
|
||||||
return (
|
passwordRequirements.map((req) => {
|
||||||
<div key={req.label} className="flex items-center gap-2">
|
const met = req.test(passwordValue);
|
||||||
<span
|
return (
|
||||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
<div
|
||||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
key= { req.label }
|
||||||
}`}
|
className = "flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-gray-200 text-gray-500"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
{
|
||||||
</span>
|
met?(
|
||||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
<Check className = "h-2.5 w-2.5" />
|
||||||
{req.label}
|
): (
|
||||||
</span>
|
<X className = "h-2.5 w-2.5" />
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
< span
|
||||||
|
className = {`text-xs ${met ? "text-primary" : "text-gray-500"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ req.label }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PasswordInput
|
< PasswordInput
|
||||||
label="Confirm password"
|
label = "Confirm password"
|
||||||
placeholder="Re-enter your password"
|
placeholder = "Re-enter your password"
|
||||||
required
|
required
|
||||||
disabled={sending}
|
disabled = { sending }
|
||||||
error={errors.confirmPassword?.message}
|
error = { errors.confirmPassword?.message }
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword") }
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{
|
||||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
error ? (
|
||||||
{error}
|
<Alert
|
||||||
</Alert>
|
color= "red"
|
||||||
|
variant = "light"
|
||||||
|
icon = {< AlertCircle size = { 18} />}
|
||||||
|
>
|
||||||
|
{ error }
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
color="edr-green"
|
color = "edr-green"
|
||||||
fullWidth
|
fullWidth
|
||||||
loading={sending}
|
loading = { sending }
|
||||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
rightSection = {!sending ? <ArrowRight size={ 16 } /> : undefined}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
< p className = "text-center text-sm text-gray-500" >
|
||||||
Already have an account?{" "}
|
Already have an account ? { " "}
|
||||||
<button
|
< button
|
||||||
type="button"
|
type = "button"
|
||||||
onClick={() => navigate("/login")}
|
onClick = {() => navigate("/login")}
|
||||||
className="font-semibold text-primary hover:underline"
|
className = "font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap= "md" >
|
||||||
<div className="mb-1 flex justify-center">
|
<div className="mb-1 flex justify-center" >
|
||||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary" >
|
||||||
<ShieldCheck size={22} />
|
<ShieldCheck size={ 22 } />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 text-center">
|
< div className = "space-y-1.5 text-center" >
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
||||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
Verify your { otpChannel === "email" ? "email" : "phone" }
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||||
We sent a 6-digit code to{" "}
|
We sent a 6 - digit code to{ " " }
|
||||||
<span className="font-medium text-gray-700">
|
<span className="font-medium text-gray-700" >
|
||||||
{otpChannel === "email"
|
{ otpChannel === "email"
|
||||||
? maskEmail(pendingData?.email ?? "")
|
? maskEmail(pendingData?.email ?? "")
|
||||||
: maskPhone(pendingData?.phone ?? "")}
|
: maskPhone(pendingData?.phone ?? "")}
|
||||||
</span>
|
</span>
|
||||||
. Enter it to finish creating your account.
|
.Enter it to finish creating your account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{otpError ? (
|
{
|
||||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
otpError ? (
|
||||||
{otpError}
|
<Alert
|
||||||
</Alert>
|
color= "red"
|
||||||
|
variant = "light"
|
||||||
|
icon = {< AlertCircle size = { 18} />}
|
||||||
|
>
|
||||||
|
{ otpError }
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack gap={6} align="center">
|
<Stack gap={ 6 } align = "center" >
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||||
Verification code
|
Verification code
|
||||||
</Text>
|
</Text>
|
||||||
<PinInput
|
< PinInput
|
||||||
length={6}
|
length = { 6}
|
||||||
type="number"
|
type = "number"
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
value={otpCode}
|
value = { otpCode }
|
||||||
placeholder="0"
|
placeholder = "0"
|
||||||
disabled={verifying}
|
disabled = { verifying }
|
||||||
styles={{ input: { textAlign: "center" } }}
|
styles = {{ input: { textAlign: "center" } }}
|
||||||
onChange={setOtpCode}
|
onChange = { setOtpCode }
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
< Button
|
||||||
color="edr-green"
|
color = "edr-green"
|
||||||
fullWidth
|
fullWidth
|
||||||
loading={verifying}
|
loading = { verifying }
|
||||||
disabled={verifying || otpCode.trim().length !== 6}
|
disabled = { verifying || otpCode.trim().length !== 6}
|
||||||
onClick={confirmOtp}
|
onClick = { confirmOtp }
|
||||||
>
|
>
|
||||||
Verify & create account
|
Verify & amp; create account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
< div className = "flex items-center justify-between" >
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color = "gray"
|
||||||
leftSection={<ArrowLeft size={14} />}
|
leftSection = {< ArrowLeft size = { 14} />}
|
||||||
disabled={sending || verifying}
|
disabled = { sending || verifying}
|
||||||
onClick={() => {
|
onClick = {() => {
|
||||||
setStage("form");
|
setStage("form");
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
< Button
|
||||||
variant="subtle"
|
variant = "subtle"
|
||||||
color="edr-green"
|
color = "edr-green"
|
||||||
leftSection={<RotateCw size={14} />}
|
leftSection = {< RotateCw size = { 14} />}
|
||||||
disabled={resendIn > 0 || sending || verifying}
|
disabled = { resendIn > 0 || sending || verifying}
|
||||||
onClick={resendOtp}
|
onClick = { resendOtp }
|
||||||
>
|
>
|
||||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
{ resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 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 { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
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 downloadBlob = (blob: Blob, filename: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
|||||||
URL.revokeObjectURL(url);
|
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({
|
export function CustomerTruckAssignmentCard({
|
||||||
booking,
|
booking,
|
||||||
onAssigned,
|
onAssigned,
|
||||||
@@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({
|
|||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
onAssigned: () => void;
|
onAssigned: () => void;
|
||||||
}) {
|
}) {
|
||||||
const assigned = Boolean(booking.customerTruckAssignedAt);
|
const queryClient = useQueryClient();
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
|
const trucksKey = ["customer-trucks", booking.id];
|
||||||
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
|
|
||||||
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
|
const { data: trucks = [], isLoading } = useQuery({
|
||||||
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
|
queryKey: trucksKey,
|
||||||
booking.customerTruckContainerNumber ?? "",
|
queryFn: () => customerTrucksService.list(booking.id),
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const [plateNumber, setPlateNumber] = useState("");
|
||||||
|
const [driverName, setDriverName] = useState("");
|
||||||
|
const [truckType, setTruckType] = useState("");
|
||||||
|
const [containers, setContainers] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Physical container numbers on this booking — the customer picks which one to
|
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||||
// load onto the truck instead of typing it. Falls back to free entry when the
|
const assignedNumbers = new Set(
|
||||||
// booking has no container numbers recorded.
|
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||||
const containerOptions = booking.containerNumbers ?? [];
|
);
|
||||||
|
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||||
|
(n) => !assignedNumbers.has(n),
|
||||||
|
);
|
||||||
|
|
||||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
||||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
// staff register + weigh what was loaded when the truck leaves.
|
||||||
|
const isExport = booking.tradeDirection === "EXPORT";
|
||||||
|
|
||||||
const submit = async () => {
|
const resetForm = () => {
|
||||||
const payload = {
|
setPlateNumber("");
|
||||||
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
|
setDriverName("");
|
||||||
driverName: driverName.trim(),
|
setTruckType("");
|
||||||
truckType: truckType.trim(),
|
setContainers([]);
|
||||||
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;
|
|
||||||
}
|
|
||||||
setError(null);
|
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 downloadFreightOrder = async () => {
|
||||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({
|
|||||||
<Truck size={18} color="#0a9f6a" />
|
<Truck size={18} color="#0a9f6a" />
|
||||||
<CardTitle>External Truck Assignment</CardTitle>
|
<CardTitle>External Truck Assignment</CardTitle>
|
||||||
</Group>
|
</Group>
|
||||||
{assigned && (
|
{trucks.length > 0 && (
|
||||||
<Group gap={6} c="#0a9f6a">
|
<Text size="sm" fw={700} c="#0a9f6a">
|
||||||
<Lock size={14} />
|
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
|
||||||
<Text size="sm" fw={700}>
|
</Text>
|
||||||
Truck Assigned
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Assigned trucks */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
trucks.map((t) => (
|
||||||
|
<Group
|
||||||
|
key={t.id}
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
|
||||||
|
>
|
||||||
|
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fz="14px" fw={700} c="#10202F">
|
||||||
|
{t.plateNumber}
|
||||||
|
</Text>
|
||||||
|
{t.arrivedAt ? (
|
||||||
|
<Badge color="green" variant="light" leftSection={<CheckCircle2 size={12} />}>
|
||||||
|
Arrived
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge color="orange" variant="light" leftSection={<Clock size={12} />}>
|
||||||
|
Awaiting arrival
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text fz="12.5px" c="#6B7C8E">
|
||||||
|
{t.driverName} · {t.truckType}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{(t.containers ?? []).map((c) => (
|
||||||
|
<Badge key={c.id} variant="outline" color="gray">
|
||||||
|
{c.containerNumber}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
{!t.arrivedAt && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label="Remove truck"
|
||||||
|
onClick={() => removeMutation.mutate(t.id)}
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert color="red" variant="light">
|
<Alert color="red" variant="light">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{assignMutation.isError && (
|
|
||||||
<Alert color="red" variant="light">
|
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
||||||
{assignMutation.error instanceof Error
|
{(isExport ? availableContainers.length > 0 : true) ? (
|
||||||
? assignMutation.error.message
|
<>
|
||||||
: "Truck assignment failed."}
|
<Divider label="Add a truck" labelPosition="center" />
|
||||||
</Alert>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Truck Plate Number"
|
||||||
|
required
|
||||||
|
value={plateNumber}
|
||||||
|
onChange={(e) => setPlateNumber(e.currentTarget.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Driver Name"
|
||||||
|
required
|
||||||
|
value={driverName}
|
||||||
|
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Truck Type"
|
||||||
|
required
|
||||||
|
data={TRUCK_TYPES}
|
||||||
|
value={truckType || null}
|
||||||
|
onChange={(value) => setTruckType(value ?? "")}
|
||||||
|
/>
|
||||||
|
{isExport && (
|
||||||
|
<MultiSelect
|
||||||
|
label="Containers to load (1–2)"
|
||||||
|
required
|
||||||
|
placeholder="Select container numbers"
|
||||||
|
data={availableContainers}
|
||||||
|
value={containers}
|
||||||
|
onChange={setContainers}
|
||||||
|
maxValues={2}
|
||||||
|
searchable
|
||||||
|
nothingFoundMessage="No unassigned containers"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={submitAdd}
|
||||||
|
loading={addMutation.isPending}
|
||||||
|
>
|
||||||
|
Add truck
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
trucks.length > 0 && (
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
All containers on this booking have been assigned to a truck.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
{trucks.length > 0 && (
|
||||||
<TextInput
|
<Group justify="flex-end">
|
||||||
label="Truck Plate Number"
|
|
||||||
required
|
|
||||||
value={truckPlateNumber}
|
|
||||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Driver Name"
|
|
||||||
required
|
|
||||||
value={driverName}
|
|
||||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Truck Type"
|
|
||||||
required
|
|
||||||
data={TRUCK_TYPES}
|
|
||||||
value={truckType || null}
|
|
||||||
onChange={(value) => setTruckType(value ?? "")}
|
|
||||||
disabled={assigned}
|
|
||||||
/>
|
|
||||||
{containerOptions.length > 0 ? (
|
|
||||||
<Select
|
|
||||||
label="Container Number to Load"
|
|
||||||
required
|
|
||||||
placeholder="Select a container from this booking"
|
|
||||||
data={containerOptions}
|
|
||||||
value={containerNumberToLoad || null}
|
|
||||||
onChange={(value) => setContainerNumberToLoad(value ?? "")}
|
|
||||||
searchable
|
|
||||||
disabled={assigned}
|
|
||||||
nothingFoundMessage="No matching container"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
label="Container Number to Load"
|
|
||||||
required
|
|
||||||
value={containerNumberToLoad}
|
|
||||||
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
{assigned ? (
|
|
||||||
<Button
|
<Button
|
||||||
|
variant="light"
|
||||||
leftSection={<Download size={16} />}
|
leftSection={<Download size={16} />}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={downloadFreightOrder}
|
onClick={downloadFreightOrder}
|
||||||
@@ -158,12 +275,8 @@ export function CustomerTruckAssignmentCard({
|
|||||||
>
|
>
|
||||||
Generate Freight Order Copies
|
Generate Freight Order Copies
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</Group>
|
||||||
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
|
)}
|
||||||
Verify & Submit Assignment
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ import type {
|
|||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
|
CheckAvailabilityPayload,
|
||||||
|
CheckAvailabilityResponse,
|
||||||
GenerateVerificationCodePayload,
|
GenerateVerificationCodePayload,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -107,6 +109,11 @@ export const api = {
|
|||||||
"setPassword",
|
"setPassword",
|
||||||
authService.setPassword,
|
authService.setPassword,
|
||||||
),
|
),
|
||||||
|
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||||
|
"auth",
|
||||||
|
"checkAvailability",
|
||||||
|
authService.checkAvailability,
|
||||||
|
),
|
||||||
sendOTP: endpoint<OtpPayload, OtpResponse>(
|
sendOTP: endpoint<OtpPayload, OtpResponse>(
|
||||||
"auth",
|
"auth",
|
||||||
"sendOTP",
|
"sendOTP",
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
GenerateVerificationCodePayload,
|
CheckAvailabilityPayload,
|
||||||
LoginPayload,
|
CheckAvailabilityResponse,
|
||||||
LoginResponse,
|
GenerateVerificationCodePayload,
|
||||||
OtpPayload,
|
LoginPayload,
|
||||||
OtpResponse,
|
LoginResponse,
|
||||||
SetPasswordPayload,
|
OtpPayload,
|
||||||
SignupPayload,
|
OtpResponse,
|
||||||
SignupResponse,
|
SetPasswordPayload,
|
||||||
|
SignupPayload,
|
||||||
|
SignupResponse,
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
import { client } from "@/utils/api";
|
import { client } from "@/utils/api";
|
||||||
import { ApiResponse } from "@edr/types";
|
import { ApiResponse } from "@edr/types";
|
||||||
@@ -23,7 +25,7 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createUser: async (body: SignupPayload) => {
|
createUser: async (body: SignupPayload) => {
|
||||||
const res = await client.post<SignupResponse & ApiResponse<void>> (
|
const res = await client.post<SignupResponse & ApiResponse<void>>(
|
||||||
URL_CONSTANTS.USERS.SIGN_UP,
|
URL_CONSTANTS.USERS.SIGN_UP,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
@@ -31,9 +33,7 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMyInfo: async () => {
|
getMyInfo: async () => {
|
||||||
const res = await client.get<AuthUser>(
|
const res = await client.get<AuthUser>(URL_CONSTANTS.USERS.ME);
|
||||||
URL_CONSTANTS.USERS.ME,
|
|
||||||
);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ export const authService = {
|
|||||||
return res.data.data;
|
return res.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||||
|
const res = await client.get<CheckAvailabilityResponse>(
|
||||||
|
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
sendOTP: async (body: OtpPayload) => {
|
sendOTP: async (body: OtpPayload) => {
|
||||||
const res = await client.post<ApiResponse<OtpResponse>>(
|
const res = await client.post<ApiResponse<OtpResponse>>(
|
||||||
URL_CONSTANTS.OTP.SEND,
|
URL_CONSTANTS.OTP.SEND,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const B = URL_CONSTANTS.BOOKINGS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck self-haul assignment for a booking (no EDR first/last mile).
|
||||||
|
* Each truck carries 1–2 of the booking's containers and tracks its own arrival.
|
||||||
|
*/
|
||||||
|
export const customerTrucksService = {
|
||||||
|
list: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
add: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: Freight.AddCustomerTruckPayload,
|
||||||
|
): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -45,6 +45,16 @@ export interface OtpResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckAvailabilityPayload {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckAvailabilityResponse {
|
||||||
|
emailTaken: boolean;
|
||||||
|
phoneTaken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetPasswordPayload {
|
export interface SetPasswordPayload {
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT,
|
||||||
|
ADD COLUMN "priceTierId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "passenger"."AppRelease" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"os" TEXT NOT NULL,
|
||||||
|
"version" TEXT NOT NULL,
|
||||||
|
"forceUpdate" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"storeLink" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable: add package_departure_station_id to Booking
|
||||||
|
ALTER TABLE "passenger"."Booking"
|
||||||
|
ADD COLUMN "packageDepartureStationId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: add package_departure_station_id to PackageBooking
|
||||||
|
ALTER TABLE "passenger"."PackageBooking"
|
||||||
|
ADD COLUMN "packageDepartureStationId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey: Booking -> Station
|
||||||
|
ALTER TABLE "passenger"."Booking"
|
||||||
|
ADD CONSTRAINT "Booking_packageDepartureStationId_fkey"
|
||||||
|
FOREIGN KEY ("packageDepartureStationId")
|
||||||
|
REFERENCES "passenger"."Station"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey: PackageBooking -> Station
|
||||||
|
ALTER TABLE "passenger"."PackageBooking"
|
||||||
|
ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey"
|
||||||
|
FOREIGN KEY ("packageDepartureStationId")
|
||||||
|
REFERENCES "passenger"."Station"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -330,7 +330,9 @@ model Station {
|
|||||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||||
stopTimes TripStopTime[]
|
stopTimes TripStopTime[]
|
||||||
crowdSignals StationCrowdSignal[]
|
crowdSignals StationCrowdSignal[]
|
||||||
|
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
|
||||||
|
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
|
||||||
@@index([city, countryCode])
|
@@index([city, countryCode])
|
||||||
@@index([sequence])
|
@@index([sequence])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -506,6 +508,8 @@ model Booking {
|
|||||||
bookingRef String @unique
|
bookingRef String @unique
|
||||||
passengerId String
|
passengerId String
|
||||||
scheduleId String
|
scheduleId String
|
||||||
|
packageId String?
|
||||||
|
priceTierId String?
|
||||||
bookingType String @default("ONE_WAY")
|
bookingType String @default("ONE_WAY")
|
||||||
status BookingStatus @default(DRAFT)
|
status BookingStatus @default(DRAFT)
|
||||||
currency String @default("ETB")
|
currency String @default("ETB")
|
||||||
@@ -539,11 +543,15 @@ model Booking {
|
|||||||
promoCode String?
|
promoCode String?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
paymentReminderSentAt DateTime?
|
paymentReminderSentAt DateTime?
|
||||||
|
packageDepartureStationId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
|
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
|
||||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||||
|
package TravelPackage? @relation(fields: [packageId], references: [id])
|
||||||
|
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
||||||
|
departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||||
seats BookingSeat[]
|
seats BookingSeat[]
|
||||||
paymentIntent PaymentIntent?
|
paymentIntent PaymentIntent?
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
@@ -1301,6 +1309,7 @@ model FraudAlert {
|
|||||||
context Json
|
context Json
|
||||||
severity String @default("MEDIUM")
|
severity String @default("MEDIUM")
|
||||||
acknowledged Boolean @default(false)
|
acknowledged Boolean @default(false)
|
||||||
|
acknowledgedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@index([iamUserId, createdAt])
|
@@index([iamUserId, createdAt])
|
||||||
@@index([acknowledged])
|
@@index([acknowledged])
|
||||||
@@ -1428,7 +1437,8 @@ model TravelPackage {
|
|||||||
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
|
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
|
||||||
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||||
priceTiers PackagePriceTier[]
|
priceTiers PackagePriceTier[]
|
||||||
bookings PackageBooking[]
|
bookings Booking[]
|
||||||
|
packageBookings PackageBooking[]
|
||||||
inquiries PackageInquiry[]
|
inquiries PackageInquiry[]
|
||||||
|
|
||||||
@@index([status, validFrom])
|
@@index([status, validFrom])
|
||||||
@@ -1446,7 +1456,8 @@ model PackagePriceTier {
|
|||||||
bookedSeats Int @default(0)
|
bookedSeats Int @default(0)
|
||||||
|
|
||||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||||
bookings PackageBooking[]
|
bookings Booking[]
|
||||||
|
packageBookings PackageBooking[]
|
||||||
inquiries PackageInquiry[]
|
inquiries PackageInquiry[]
|
||||||
|
|
||||||
@@unique([packageId, seatType])
|
@@unique([packageId, seatType])
|
||||||
@@ -1472,10 +1483,12 @@ model PackageBooking {
|
|||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
packageDepartureStationId String?
|
||||||
|
|
||||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||||
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||||
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||||
|
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||||
passengers PackageBookingPassenger[]
|
passengers PackageBookingPassenger[]
|
||||||
paymentIntent PackagePaymentIntent?
|
paymentIntent PackagePaymentIntent?
|
||||||
|
|
||||||
@@ -1536,3 +1549,17 @@ model PackageInquiry {
|
|||||||
@@index([packageId])
|
@@index([packageId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AppRelease {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
os String // "android" | "ios"
|
||||||
|
version String
|
||||||
|
forceUpdate Boolean @default(false)
|
||||||
|
storeLink String?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([os, version])
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|||||||
@@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedPackageBookings() {
|
||||||
|
const pkg = await prisma.travelPackage.findFirst({ where: { code: 'KULUBBI-2025' }, include: { priceTiers: true } });
|
||||||
|
if (!pkg || !pkg.priceTiers.length) {
|
||||||
|
console.log(' ⚠️ Kulubbi package not found, skipping package booking seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tier = pkg.priceTiers[0];
|
||||||
|
const passenger = await prisma.passenger.findFirst();
|
||||||
|
|
||||||
|
const existing = await prisma.packageBooking.findFirst({ where: { bookingRef: 'PKG-SEED01' } });
|
||||||
|
if (existing) { console.log(' ℹ️ Package booking seed already exists'); return; }
|
||||||
|
|
||||||
|
await prisma.packageBooking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: 'PKG-SEED01',
|
||||||
|
packageId: pkg.id,
|
||||||
|
priceTierId: tier.id,
|
||||||
|
passengerId: passenger?.id ?? null,
|
||||||
|
contactEmail: 'kelemu@email.com',
|
||||||
|
contactPhone: '+251911234567',
|
||||||
|
passengerCount: 2,
|
||||||
|
totalMinor: tier.priceMinor * 2,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
displayTotalMinor: tier.priceMinor * 2,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
passengers: {
|
||||||
|
create: [
|
||||||
|
{ passengerName: 'Abebe Kebede', idDocumentType: 'NATIONAL_ID' },
|
||||||
|
{ passengerName: 'Tigist Alemu', idDocumentType: 'NATIONAL_ID' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(' ✅ Sample package booking created (PKG-SEED01)');
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||||
|
|
||||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||||
['System Users', seedSystemUsers],
|
// ['System Users', seedSystemUsers],
|
||||||
['Stations', seedStations],
|
// ['Stations', seedStations],
|
||||||
['Coach Types & Classes', seedCoachTypesAndClasses],
|
// ['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||||
['Route', seedRoute],
|
// ['Route', seedRoute],
|
||||||
['Coaches', seedCoaches],
|
// ['Coaches', seedCoaches],
|
||||||
['Trips', seedTrips],
|
// ['Trips', seedTrips],
|
||||||
['Fare Rules', seedFareRules],
|
// ['Fare Rules', seedFareRules],
|
||||||
['Currency', seedCurrency],
|
// ['Currency', seedCurrency],
|
||||||
['Payment Methods', seedPaymentMethods],
|
// ['Payment Methods', seedPaymentMethods],
|
||||||
['Segment Fares', seedSegmentFares],
|
// ['Segment Fares', seedSegmentFares],
|
||||||
['Notification Templates', seedNotificationTemplates],
|
// ['Notification Templates', seedNotificationTemplates],
|
||||||
['Menu & Food', seedMenuAndFood],
|
// ['Menu & Food', seedMenuAndFood],
|
||||||
['Promotions', seedPromotions],
|
// ['Promotions', seedPromotions],
|
||||||
['FAQ', seedFAQ],
|
// ['FAQ', seedFAQ],
|
||||||
['Fraud Rules', seedFraudRules],
|
// ['Fraud Rules', seedFraudRules],
|
||||||
['Kulubbi Package', seedKulubbiPackage],
|
// ['Kulubbi Package', seedKulubbiPackage],
|
||||||
|
// ['Package Bookings', seedPackageBookings],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ import { PackagesModule } from './modules/packages/packages.module';
|
|||||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { TasksModule } from './modules/tasks/tasks.module';
|
import { TasksModule } from './modules/tasks/tasks.module';
|
||||||
|
import { AppReleasesModule } from './modules/app-releases/app-releases.module';
|
||||||
|
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
|
||||||
|
import { SegmentFareSeeder } from './seed/segment-fare.seeder';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -130,6 +133,8 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
|||||||
ExcessBaggageModule,
|
ExcessBaggageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
|
AppReleasesModule,
|
||||||
|
ConfigurableFareModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||||
@@ -137,6 +142,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
|||||||
DynamicThrottlerGuard,
|
DynamicThrottlerGuard,
|
||||||
EdrPassengerOrgSeeder,
|
EdrPassengerOrgSeeder,
|
||||||
PassengerStaffUsersSeeder,
|
PassengerStaffUsersSeeder,
|
||||||
|
SegmentFareSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -145,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||||
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||||
|
private readonly segmentFareSeeder: SegmentFareSeeder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -163,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await this.segmentFareSeeder.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
@@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
const response = ctx.getResponse();
|
const response = ctx.getResponse();
|
||||||
const request = ctx.getRequest();
|
const request = ctx.getRequest();
|
||||||
|
|
||||||
|
let prismaMessage: string | null = null;
|
||||||
|
if (exception instanceof PrismaClientKnownRequestError) {
|
||||||
|
if (exception.code === 'P2003') {
|
||||||
|
const field = (exception.meta?.field_name as string | undefined) ?? 'a related record';
|
||||||
|
prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`;
|
||||||
|
} else if (exception.code === 'P2025') {
|
||||||
|
prismaMessage = 'Record not found.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
? exception.getStatus()
|
? exception.getStatus()
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
: prismaMessage
|
||||||
|
? HttpStatus.BAD_REQUEST
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
const messageRaw =
|
const messageRaw =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
: 'Internal server error';
|
: prismaMessage ?? 'Internal server error';
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
typeof messageRaw === 'string'
|
typeof messageRaw === 'string'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { AppReleasesService, AppReleaseDto } from './app-releases.service';
|
||||||
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
|
@ApiTags('App Releases')
|
||||||
|
@Controller('app-releases')
|
||||||
|
export class AppReleasesController {
|
||||||
|
constructor(private service: AppReleasesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({ summary: 'List all app releases (public)' })
|
||||||
|
getAll() {
|
||||||
|
return this.service.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest/:os')
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({ summary: 'Get latest release for a given OS (public)' })
|
||||||
|
@ApiParam({ name: 'os', enum: ['android', 'ios'] })
|
||||||
|
getLatest(@Param('os') os: string) {
|
||||||
|
return this.service.getLatest(os);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@PassengerStaff(PASSENGER_PERMS.admin)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Create an app release (admin)' })
|
||||||
|
create(@Body() dto: AppReleaseDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@PassengerStaff(PASSENGER_PERMS.admin)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Update an app release (admin)' })
|
||||||
|
update(@Param('id') id: string, @Body() dto: Partial<AppReleaseDto>) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@PassengerStaff(PASSENGER_PERMS.admin)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Delete an app release (admin)' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppReleasesController } from './app-releases.controller';
|
||||||
|
import { AppReleasesService } from './app-releases.service';
|
||||||
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AppReleasesController],
|
||||||
|
providers: [AppReleasesService],
|
||||||
|
})
|
||||||
|
export class AppReleasesModule {}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
|
||||||
|
export class AppReleaseDto {
|
||||||
|
@ApiProperty({ enum: ['android', 'ios'] })
|
||||||
|
@IsIn(['android', 'ios'])
|
||||||
|
os: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1.2.3' })
|
||||||
|
@IsString()
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
@ApiProperty({ default: false })
|
||||||
|
@IsBoolean()
|
||||||
|
forceUpdate: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeLink?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppReleasesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private get db() {
|
||||||
|
return (this.prisma as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatest(os: string) {
|
||||||
|
const release = await this.db.appRelease.findFirst({
|
||||||
|
where: { os },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (!release) throw new NotFoundException(`No release found for ${os}`);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: AppReleaseDto) {
|
||||||
|
const existing = await this.db.appRelease.findUnique({
|
||||||
|
where: { os_version: { os: dto.os, version: dto.version } },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`);
|
||||||
|
return this.db.appRelease.create({ data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: Partial<AppReleaseDto>) {
|
||||||
|
const release = await this.db.appRelease.findUnique({ where: { id } });
|
||||||
|
if (!release) throw new NotFoundException('App release not found');
|
||||||
|
return this.db.appRelease.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const release = await this.db.appRelease.findUnique({ where: { id } });
|
||||||
|
if (!release) throw new NotFoundException('App release not found');
|
||||||
|
await this.db.appRelease.delete({ where: { id } });
|
||||||
|
return { deleted: true, id };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ export class AuditController {
|
|||||||
entityType: entityType || undefined,
|
entityType: entityType || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = await this.auditService.getLogs(filters);
|
const result = await this.auditService.getLogs(filters);
|
||||||
return { items };
|
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('logs/:id')
|
@Get('logs/:id')
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export class CreateBookingDto {
|
|||||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
||||||
passengers: PassengerInputDto[];
|
passengers: PassengerInputDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' })
|
||||||
|
@IsOptional() @IsString() packageId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||||
|
@IsOptional() @IsString() priceTierId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||||
@IsOptional() @IsString() promoCode?: string;
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -202,9 +202,12 @@ export class BookingsService {
|
|||||||
async findAll(filters: BookingFilters = {}) {
|
async findAll(filters: BookingFilters = {}) {
|
||||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const onlyPackages = bookingType === 'PACKAGE';
|
||||||
|
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
||||||
`SELECT u.id FROM iam.users u
|
`SELECT u.id FROM iam.users u
|
||||||
@@ -229,10 +232,10 @@ export class BookingsService {
|
|||||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||||
if (bookingType) where.bookingType = bookingType;
|
if (bookingType && !onlyPackages) where.bookingType = bookingType;
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
where.createdAt = {
|
where.createdAt = {
|
||||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||||
@@ -240,17 +243,126 @@ export class BookingsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (paymentStatus) {
|
if (paymentStatus) {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
|
||||||
PAID: 'SUCCEEDED',
|
|
||||||
PENDING: 'REQUIRES_ACTION',
|
|
||||||
FAILED: 'FAILED',
|
|
||||||
REFUNDED: 'REFUNDED',
|
|
||||||
};
|
|
||||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||||
where.paymentIntent = { is: { status: mapped } };
|
where.paymentIntent = { is: { status: mapped } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const pkgWhere: any = {};
|
||||||
|
if (search) {
|
||||||
|
pkgWhere.OR = [
|
||||||
|
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ contactEmail: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ contactPhone: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (status) pkgWhere.status = status;
|
||||||
|
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
|
||||||
|
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
|
||||||
|
|
||||||
|
if (onlyPackages) {
|
||||||
|
// Package bookings live in two places:
|
||||||
|
// 1. PackageBooking table (dedicated package bookings)
|
||||||
|
// 2. Booking table with packageId != null (round-trip bookings linked to a package)
|
||||||
|
const bookingPkgWhere: any = { packageId: { not: null } };
|
||||||
|
if (status) bookingPkgWhere.status = status;
|
||||||
|
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
|
||||||
|
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||||
|
if (search) bookingPkgWhere.OR = where.OR;
|
||||||
|
|
||||||
|
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
|
||||||
|
this.prisma.packageBooking.findMany({
|
||||||
|
where: pkgWhere,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
package: { select: { id: true, name: true, code: true } },
|
||||||
|
priceTier: { select: { id: true, label: true, seatType: true } },
|
||||||
|
passengers: true,
|
||||||
|
paymentIntent: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.packageBooking.count({ where: pkgWhere }),
|
||||||
|
this.prisma.booking.findMany({
|
||||||
|
where: bookingPkgWhere,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
paymentIntent: true,
|
||||||
|
seats: { include: { seat: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.booking.count({ where: bookingPkgWhere }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
|
||||||
|
const iamRows = iamUserIds.length > 0
|
||||||
|
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||||
|
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||||
|
[iamUserIds],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
const mappedRegPkg = regPkgItems.map((booking: any) => {
|
||||||
|
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||||
|
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
|
||||||
|
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||||
|
return {
|
||||||
|
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
|
totalMinor: booking.totalMinor, currency: 'ETB',
|
||||||
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||||
|
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||||
|
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
|
||||||
|
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||||
|
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||||
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
|
passengers: uniquePassengers,
|
||||||
|
schedule: booking.schedule ? {
|
||||||
|
train: booking.schedule.train,
|
||||||
|
originStation: booking.schedule.originStation,
|
||||||
|
destinationStation: booking.schedule.destinationStation,
|
||||||
|
departureAt: booking.schedule.departureAt,
|
||||||
|
} : null,
|
||||||
|
paymentIntent: booking.paymentIntent,
|
||||||
|
seatCount: booking.seats.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedPkg = pkgItems.map((b: any) => ({
|
||||||
|
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
||||||
|
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
|
||||||
|
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||||
|
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||||
|
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||||
|
isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
|
||||||
|
tierLabel: b.priceTier?.label ?? null,
|
||||||
|
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
|
||||||
|
createdAt: b.createdAt, passenger: null,
|
||||||
|
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
|
||||||
|
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
|
||||||
|
schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const total = pkgTotal + regPkgTotal;
|
||||||
|
const allItems = [...mappedPkg, ...mappedRegPkg]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: allItems,
|
||||||
|
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([
|
||||||
this.prisma.booking.findMany({
|
this.prisma.booking.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
@@ -261,12 +373,27 @@ export class BookingsService {
|
|||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
|
package: { select: { id: true, name: true, code: true } },
|
||||||
|
priceTier: { select: { id: true, label: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
|
includePackageBookings
|
||||||
|
? this.prisma.packageBooking.findMany({
|
||||||
|
where: pkgWhere,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
package: { select: { id: true, name: true, code: true } },
|
||||||
|
priceTier: { select: { id: true, label: true, seatType: true } },
|
||||||
|
passengers: true,
|
||||||
|
paymentIntent: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([] as any[]),
|
||||||
|
includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[];
|
const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
|
||||||
const iamRows = iamUserIds.length > 0
|
const iamRows = iamUserIds.length > 0
|
||||||
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||||
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||||
@@ -275,59 +402,86 @@ export class BookingsService {
|
|||||||
: [];
|
: [];
|
||||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
const mappedRegular = regularItems.map((booking: any) => {
|
||||||
|
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||||
|
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
|
||||||
|
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
bookingRef: booking.bookingRef,
|
||||||
|
status: booking.status,
|
||||||
|
totalMinor: booking.totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency: booking.displayCurrency,
|
||||||
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
|
contactEmail: booking.contactEmail,
|
||||||
|
contactPhone: booking.contactPhone,
|
||||||
|
bookingType: booking.bookingType,
|
||||||
|
packageId: booking.packageId ?? null,
|
||||||
|
priceTierId: (booking as any).priceTierId ?? null,
|
||||||
|
packageName: (booking as any).package?.name ?? null,
|
||||||
|
packageCode: (booking as any).package?.code ?? null,
|
||||||
|
tierLabel: (booking as any).priceTier?.label ?? null,
|
||||||
|
isPackageBooking: !!booking.packageId,
|
||||||
|
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||||
|
adultCount: booking.adultCount,
|
||||||
|
childCount: booking.childCount,
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||||
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
|
passengers: uniquePassengers,
|
||||||
|
schedule: {
|
||||||
|
train: booking.schedule.train,
|
||||||
|
originStation: booking.schedule.originStation,
|
||||||
|
destinationStation: booking.schedule.destinationStation,
|
||||||
|
departureAt: booking.schedule.departureAt,
|
||||||
|
},
|
||||||
|
paymentIntent: booking.paymentIntent,
|
||||||
|
seatCount: booking.seats.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappedPkg = pkgItems.map((b: any) => ({
|
||||||
|
id: b.id,
|
||||||
|
bookingRef: b.bookingRef,
|
||||||
|
status: b.status,
|
||||||
|
totalMinor: b.totalMinor,
|
||||||
|
currency: b.currency || 'ETB',
|
||||||
|
displayCurrency: b.displayCurrency,
|
||||||
|
displayTotalMinor: b.displayTotalMinor,
|
||||||
|
contactEmail: b.contactEmail,
|
||||||
|
contactPhone: b.contactPhone,
|
||||||
|
bookingType: 'PACKAGE',
|
||||||
|
packageId: b.packageId,
|
||||||
|
priceTierId: b.priceTierId,
|
||||||
|
isPackageBooking: true,
|
||||||
|
packageName: b.package?.name,
|
||||||
|
packageCode: b.package?.code,
|
||||||
|
tierLabel: b.priceTier?.label ?? null,
|
||||||
|
returnLegStatus: null,
|
||||||
|
adultCount: b.passengerCount,
|
||||||
|
childCount: 0,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
passenger: null,
|
||||||
|
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
|
||||||
|
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
|
||||||
|
schedule: null,
|
||||||
|
paymentIntent: b.paymentIntent,
|
||||||
|
seatCount: b.passengerCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const total = regularTotal + pkgTotal;
|
||||||
|
const allItems = [...mappedRegular, ...mappedPkg]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, pageSize);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(booking => {
|
items: allItems,
|
||||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
||||||
// Build passenger list with categories
|
|
||||||
const passengerDetails = booking.seats.map((s: any) => ({
|
|
||||||
name: s.passengerName,
|
|
||||||
category: s.passengerCategory // 'ADULT' or 'CHILD'
|
|
||||||
}));
|
|
||||||
// Get unique names with their categories
|
|
||||||
const uniquePassengers = Array.from(
|
|
||||||
new Map(passengerDetails.map(p => [p.name, p])).values()
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: booking.id,
|
|
||||||
bookingRef: booking.bookingRef,
|
|
||||||
status: booking.status,
|
|
||||||
totalMinor: booking.totalMinor,
|
|
||||||
currency: 'ETB',
|
|
||||||
displayCurrency: booking.displayCurrency,
|
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
|
||||||
contactEmail: booking.contactEmail,
|
|
||||||
contactPhone: booking.contactPhone,
|
|
||||||
bookingType: booking.bookingType,
|
|
||||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
|
||||||
adultCount: booking.adultCount,
|
|
||||||
childCount: booking.childCount,
|
|
||||||
createdAt: booking.createdAt,
|
|
||||||
passenger: iam
|
|
||||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
|
||||||
: null,
|
|
||||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
|
||||||
passengers: uniquePassengers, // Include category info
|
|
||||||
schedule: {
|
|
||||||
train: booking.schedule.train,
|
|
||||||
originStation: booking.schedule.originStation,
|
|
||||||
destinationStation: booking.schedule.destinationStation,
|
|
||||||
departureAt: booking.schedule.departureAt,
|
|
||||||
},
|
|
||||||
paymentIntent: booking.paymentIntent,
|
|
||||||
seatCount: booking.seats.length,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
meta: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / pageSize),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateBookingDto) {
|
async create(dto: CreateBookingDto) {
|
||||||
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
|
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
|
||||||
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
|
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
|
||||||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
|
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
|
||||||
@@ -363,7 +517,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
const fareCalculation = dto.packageId && dto.priceTierId
|
||||||
|
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
||||||
|
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = fareCalculation.totalMinor;
|
let displayTotalMinor = fareCalculation.totalMinor;
|
||||||
@@ -371,20 +527,18 @@ export class BookingsService {
|
|||||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which child gets free fare (first child encountered)
|
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||||
|
// for regular bookings the first child is free.
|
||||||
let freeChildUsed = false;
|
let freeChildUsed = false;
|
||||||
const passengersWithFares = passengersData.map(p => {
|
const passengersWithFares = passengersData.map(p => {
|
||||||
let fareMinor: number;
|
let fareMinor: number;
|
||||||
if (p.category === PassengerCategory.ADULT) {
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
fareMinor = fareCalculation.baseFareMinor;
|
fareMinor = fareCalculation.baseFareMinor;
|
||||||
|
} else if (dto.packageId) {
|
||||||
|
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
|
||||||
} else {
|
} else {
|
||||||
// Child: first child is free, subsequent children pay full fare
|
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||||
if (!freeChildUsed) {
|
else fareMinor = fareCalculation.baseFareMinor;
|
||||||
fareMinor = 0;
|
|
||||||
freeChildUsed = true;
|
|
||||||
} else {
|
|
||||||
fareMinor = fareCalculation.baseFareMinor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { ...p, fareMinor };
|
return { ...p, fareMinor };
|
||||||
});
|
});
|
||||||
@@ -401,6 +555,7 @@ export class BookingsService {
|
|||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
seats: {
|
seats: {
|
||||||
create: passengersWithFares.map(p => ({
|
create: passengersWithFares.map(p => ({
|
||||||
seat: { connect: { id: p.seatId } },
|
seat: { connect: { id: p.seatId } },
|
||||||
@@ -421,6 +576,12 @@ export class BookingsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
||||||
|
if (dto.packageId && dto.priceTierId) {
|
||||||
|
await this.prisma.packagePriceTier.update({
|
||||||
|
where: { id: dto.priceTierId },
|
||||||
|
data: { bookedSeats: { increment: passengersData.length } },
|
||||||
|
});
|
||||||
|
}
|
||||||
this.eventEmitter.emit('booking.created', { booking });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
return { ...booking, fareBreakdown: fareCalculation };
|
return { ...booking, fareBreakdown: fareCalculation };
|
||||||
}
|
}
|
||||||
@@ -468,23 +629,38 @@ export class BookingsService {
|
|||||||
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
|
||||||
const [outboundFare, returnFare] = await Promise.all([
|
// Package bookings use fixed tier price split equally across both legs
|
||||||
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||||
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
|
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||||
]);
|
let combinedBaseFareMinor: number;
|
||||||
|
|
||||||
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
|
||||||
let discountMinor = 0;
|
let discountMinor = 0;
|
||||||
if (dto.promoCode) {
|
let loyaltyMinor = 0;
|
||||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
let totalMinor: number;
|
||||||
if (promo?.active && promo.validUntil > new Date()) {
|
|
||||||
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
if (dto.packageId && dto.priceTierId) {
|
||||||
|
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
|
||||||
|
// Split evenly across both legs for per-seat fare recording
|
||||||
|
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
|
||||||
|
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
|
||||||
|
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
|
||||||
|
combinedBaseFareMinor = pkgFare.totalBaseFareMinor;
|
||||||
|
totalMinor = pkgFare.totalMinor;
|
||||||
|
} else {
|
||||||
|
[outboundFare, returnFare] = await Promise.all([
|
||||||
|
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||||
|
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
|
||||||
|
]);
|
||||||
|
combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
||||||
|
if (dto.promoCode) {
|
||||||
|
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||||
|
if (promo?.active && promo.validUntil > new Date()) {
|
||||||
|
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
|
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||||
|
}
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = totalMinor;
|
let displayTotalMinor = totalMinor;
|
||||||
@@ -492,34 +668,27 @@ export class BookingsService {
|
|||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which child gets free fare for outbound and return legs
|
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||||
|
// for regular bookings the first child is free per leg.
|
||||||
let outboundFreeChildUsed = false;
|
let outboundFreeChildUsed = false;
|
||||||
let returnFreeChildUsed = false;
|
let returnFreeChildUsed = false;
|
||||||
const passengersWithFares = passengersData.map(p => {
|
const passengersWithFares = passengersData.map(p => {
|
||||||
let outboundFareMinor: number;
|
let outboundFareMinor: number;
|
||||||
let returnFareMinor: number;
|
let returnFareMinor: number;
|
||||||
|
|
||||||
if (p.category === PassengerCategory.ADULT) {
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
outboundFareMinor = outboundFare.baseFareMinor;
|
outboundFareMinor = outboundFare.baseFareMinor;
|
||||||
returnFareMinor = returnFare.baseFareMinor;
|
returnFareMinor = returnFare.baseFareMinor;
|
||||||
|
} else if (dto.packageId) {
|
||||||
|
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
|
||||||
|
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
|
||||||
} else {
|
} else {
|
||||||
// Child fare for outbound
|
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||||
if (!outboundFreeChildUsed) {
|
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||||
outboundFareMinor = 0;
|
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||||
outboundFreeChildUsed = true;
|
else returnFareMinor = returnFare.baseFareMinor;
|
||||||
} else {
|
|
||||||
outboundFareMinor = outboundFare.baseFareMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Child fare for return
|
|
||||||
if (!returnFreeChildUsed) {
|
|
||||||
returnFareMinor = 0;
|
|
||||||
returnFreeChildUsed = true;
|
|
||||||
} else {
|
|
||||||
returnFareMinor = returnFare.baseFareMinor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...p, outboundFareMinor, returnFareMinor };
|
return { ...p, outboundFareMinor, returnFareMinor };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,6 +710,7 @@ export class BookingsService {
|
|||||||
returnHoldId: dto.returnHoldId,
|
returnHoldId: dto.returnHoldId,
|
||||||
returnSeatClassId: dto.returnSeatClassId,
|
returnSeatClassId: dto.returnSeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersWithFares.map(p => ({
|
...passengersWithFares.map(p => ({
|
||||||
@@ -586,6 +756,13 @@ export class BookingsService {
|
|||||||
this.seatsService.confirmSeats(returnSeatIds)
|
this.seatsService.confirmSeats(returnSeatIds)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (dto.packageId && dto.priceTierId) {
|
||||||
|
await this.prisma.packagePriceTier.update({
|
||||||
|
where: { id: dto.priceTierId },
|
||||||
|
data: { bookedSeats: { increment: passengersData.length } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit('booking.created', { booking });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1048,6 +1225,34 @@ export class BookingsService {
|
|||||||
return { adultCount, childCount };
|
return { adultCount, childCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async calculatePackageFare(
|
||||||
|
priceTierId: string,
|
||||||
|
adultCount: number,
|
||||||
|
childCount: number,
|
||||||
|
) {
|
||||||
|
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
|
||||||
|
// For round-trip packages the caller splits the tier price across legs, so
|
||||||
|
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare.
|
||||||
|
const childFareMinor = Math.round(tier.priceMinor * 0.1);
|
||||||
|
const adultFareMinor = tier.priceMinor * adultCount;
|
||||||
|
const childTotalMinor = childFareMinor * childCount;
|
||||||
|
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
|
||||||
|
return {
|
||||||
|
baseFareMinor: tier.priceMinor,
|
||||||
|
adultCount,
|
||||||
|
adultFareMinor,
|
||||||
|
childCount,
|
||||||
|
freeChildrenCount: 0,
|
||||||
|
paidChildrenCount: childCount,
|
||||||
|
childFareMinor: childTotalMinor,
|
||||||
|
totalBaseFareMinor,
|
||||||
|
discountMinor: 0,
|
||||||
|
loyaltyRedemptionMinor: 0,
|
||||||
|
taxesFeesMinor: 0,
|
||||||
|
totalMinor: totalBaseFareMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async calculateFare(
|
private async calculateFare(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatClassId: string,
|
seatClassId: string,
|
||||||
@@ -1182,7 +1387,64 @@ export class BookingsService {
|
|||||||
paymentIntent: true, tickets: { take: 1 },
|
paymentIntent: true, tickets: { take: 1 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
|
||||||
|
if (!booking) {
|
||||||
|
// Fall back to PackageBooking
|
||||||
|
const pkgBooking = await this.prisma.packageBooking.findUnique({
|
||||||
|
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
|
||||||
|
include: {
|
||||||
|
package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } },
|
||||||
|
priceTier: true,
|
||||||
|
passengers: true,
|
||||||
|
paymentIntent: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!pkgBooking) throw new NotFoundException('Booking not found');
|
||||||
|
return {
|
||||||
|
id: pkgBooking.id,
|
||||||
|
bookingRef: pkgBooking.bookingRef,
|
||||||
|
status: pkgBooking.status,
|
||||||
|
totalMinor: pkgBooking.totalMinor,
|
||||||
|
currency: pkgBooking.currency || 'ETB',
|
||||||
|
adultCount: pkgBooking.passengerCount,
|
||||||
|
childCount: 0,
|
||||||
|
displayCurrency: pkgBooking.displayCurrency,
|
||||||
|
displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined,
|
||||||
|
bookingType: 'PACKAGE',
|
||||||
|
packageId: pkgBooking.packageId,
|
||||||
|
priceTierId: pkgBooking.priceTierId,
|
||||||
|
packageName: (pkgBooking as any).package?.name,
|
||||||
|
packageCode: (pkgBooking as any).package?.code,
|
||||||
|
tierLabel: (pkgBooking as any).priceTier?.label,
|
||||||
|
isPackageBooking: true,
|
||||||
|
returnLegStatus: null,
|
||||||
|
contactEmail: pkgBooking.contactEmail,
|
||||||
|
contactPhone: pkgBooking.contactPhone,
|
||||||
|
createdAt: pkgBooking.createdAt,
|
||||||
|
schedule: (pkgBooking as any).package?.outboundSchedule ? {
|
||||||
|
id: (pkgBooking as any).package.outboundSchedule.id,
|
||||||
|
trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number,
|
||||||
|
trainName: (pkgBooking as any).package.outboundSchedule.train?.name,
|
||||||
|
origin: (pkgBooking as any).package.outboundSchedule.originStation,
|
||||||
|
destination: (pkgBooking as any).package.outboundSchedule.destinationStation,
|
||||||
|
departureAt: (pkgBooking as any).package.outboundSchedule.departureAt,
|
||||||
|
arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt,
|
||||||
|
} : null,
|
||||||
|
passengers: (pkgBooking as any).passengers?.map((p: any) => ({
|
||||||
|
fullName: p.passengerName,
|
||||||
|
category: 'ADULT',
|
||||||
|
leg: 1,
|
||||||
|
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
||||||
|
verifaydaVerified: false,
|
||||||
|
seat: null,
|
||||||
|
})),
|
||||||
|
payment: (pkgBooking as any).paymentIntent
|
||||||
|
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
|
||||||
|
: undefined,
|
||||||
|
ticket: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
totalMinor: booking.totalMinor, currency: 'ETB',
|
totalMinor: booking.totalMinor, currency: 'ETB',
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ export class CreateGuestBookingDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
||||||
@IsOptional() @IsString() deviceId?: string;
|
@IsOptional() @IsString() deviceId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' })
|
||||||
|
@IsOptional() @IsString() packageId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||||
|
@IsOptional() @IsString() priceTierId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SavedPassengerProfileDto {
|
export class SavedPassengerProfileDto {
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ export class GuestBookingService {
|
|||||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
}
|
}
|
||||||
|
|
||||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||||||
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||||||
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||||
|
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
@@ -142,21 +144,34 @@ export class GuestBookingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate fare
|
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
|
||||||
const primaryNationality = passengersData[0]?.nationality;
|
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
||||||
const baseFareMinor = await this.getBaseFare(
|
let baseFareMinor: number;
|
||||||
dto.scheduleId,
|
let paidChildrenCount: number;
|
||||||
dto.seatClassId,
|
let childUnitFare: number;
|
||||||
segmentRoute,
|
|
||||||
fullRoute,
|
if (isPackageOneway) {
|
||||||
primaryNationality,
|
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||||
dto.originStationId,
|
baseFareMinor = tier.priceMinor;
|
||||||
dto.destinationStationId,
|
paidChildrenCount = childCount;
|
||||||
);
|
childUnitFare = Math.round(baseFareMinor * 0.1);
|
||||||
|
} else {
|
||||||
|
const primaryNationality = passengersData[0]?.nationality;
|
||||||
|
baseFareMinor = await this.getBaseFare(
|
||||||
|
dto.scheduleId,
|
||||||
|
dto.seatClassId,
|
||||||
|
segmentRoute,
|
||||||
|
fullRoute,
|
||||||
|
primaryNationality,
|
||||||
|
dto.originStationId,
|
||||||
|
dto.destinationStationId,
|
||||||
|
);
|
||||||
|
paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
|
childUnitFare = baseFareMinor;
|
||||||
|
}
|
||||||
|
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
const adultFareMinor = baseFareMinor * adultCount;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const childFareMinor = childUnitFare * paidChildrenCount;
|
||||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
|
||||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||||
|
|
||||||
let discountMinor = 0;
|
let discountMinor = 0;
|
||||||
@@ -215,6 +230,7 @@ export class GuestBookingService {
|
|||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: firstPassenger.email || null,
|
contactEmail: firstPassenger.email || null,
|
||||||
contactPhone: firstPassenger.phone || null,
|
contactPhone: firstPassenger.phone || null,
|
||||||
@@ -229,7 +245,7 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -256,7 +272,7 @@ export class GuestBookingService {
|
|||||||
adultCount,
|
adultCount,
|
||||||
adultFareMinor,
|
adultFareMinor,
|
||||||
childCount,
|
childCount,
|
||||||
freeChildrenCount: Math.min(childCount, 1),
|
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
|
||||||
paidChildrenCount,
|
paidChildrenCount,
|
||||||
childFareMinor,
|
childFareMinor,
|
||||||
totalBaseFareMinor,
|
totalBaseFareMinor,
|
||||||
@@ -306,10 +322,16 @@ export class GuestBookingService {
|
|||||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const synth = (sched: any, stationId: string, seq: number) => {
|
||||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
||||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
return { stationId, sequence: seq, station };
|
||||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
};
|
||||||
|
const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
|
||||||
|
const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
|
||||||
|
const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
|
||||||
|
const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
|
||||||
|
const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
|
||||||
|
const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
|
||||||
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
||||||
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
||||||
|
|
||||||
@@ -358,18 +380,36 @@ export class GuestBookingService {
|
|||||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate fares for both legs
|
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
|
||||||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||||||
const primaryNationality = passengersData[0]?.nationality;
|
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
|
||||||
|
let outboundBaseFare: number;
|
||||||
|
let returnBaseFare: number;
|
||||||
|
let paidChildrenCount: number;
|
||||||
|
let outboundChildUnitFare: number;
|
||||||
|
let returnChildUnitFare: number;
|
||||||
|
|
||||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
if (isPackageRoundTrip) {
|
||||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
|
||||||
]);
|
const halfMinor = Math.round(tier.priceMinor / 2);
|
||||||
|
outboundBaseFare = halfMinor;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
returnBaseFare = tier.priceMinor - halfMinor;
|
||||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
paidChildrenCount = childCount;
|
||||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
|
||||||
|
returnChildUnitFare = Math.round(returnBaseFare * 0.1);
|
||||||
|
} else {
|
||||||
|
const primaryNationality = passengersData[0]?.nationality;
|
||||||
|
[outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||||
|
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||||
|
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||||
|
]);
|
||||||
|
paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
|
outboundChildUnitFare = outboundBaseFare;
|
||||||
|
returnChildUnitFare = returnBaseFare;
|
||||||
|
}
|
||||||
|
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
|
||||||
|
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
|
||||||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||||||
|
|
||||||
let discountMinor = 0;
|
let discountMinor = 0;
|
||||||
@@ -415,6 +455,7 @@ export class GuestBookingService {
|
|||||||
returnHoldId: dto.returnHoldId,
|
returnHoldId: dto.returnHoldId,
|
||||||
returnSeatClassId,
|
returnSeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: passengersData[0]?.email || null,
|
contactEmail: passengersData[0]?.email || null,
|
||||||
contactPhone: passengersData[0]?.phone || null,
|
contactPhone: passengersData[0]?.phone || null,
|
||||||
@@ -432,7 +473,7 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
|
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
...passengersData.map((p) => ({
|
...passengersData.map((p) => ({
|
||||||
@@ -447,7 +488,7 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
|
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
@@ -476,7 +517,7 @@ export class GuestBookingService {
|
|||||||
returnBaseFareMinor: returnBaseFare,
|
returnBaseFareMinor: returnBaseFare,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
freeChildrenCount: Math.min(childCount, 1),
|
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
|
||||||
paidChildrenCount,
|
paidChildrenCount,
|
||||||
combinedBaseFareMinor,
|
combinedBaseFareMinor,
|
||||||
discountMinor,
|
discountMinor,
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export class ConfigurableFareService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (childRule) {
|
if (childRule) {
|
||||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
const freeChildren = Math.min(childCount, adultCount);
|
||||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||||
|
|
||||||
if (freeChildren > 0) {
|
if (freeChildren > 0) {
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ export class FareEngineService {
|
|||||||
|
|
||||||
const adultCount = dto.adultCount ?? 1;
|
const adultCount = dto.adultCount ?? 1;
|
||||||
const childCount = dto.childCount ?? 0;
|
const childCount = dto.childCount ?? 0;
|
||||||
const freeChildrenCount = Math.min(childCount, 1);
|
const freeChildrenCount = Math.min(childCount, adultCount);
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
|
||||||
|
|
||||||
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||||
// First child is free, but pays premium and insurance
|
// First child is free, but pays premium and insurance
|
||||||
@@ -169,7 +169,7 @@ export class FareEngineService {
|
|||||||
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
||||||
``,
|
``,
|
||||||
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
|
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
|
||||||
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
|
`Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`,
|
||||||
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
|
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
|
||||||
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
|
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
|
||||||
``,
|
``,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||||
import { PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
@@ -48,6 +48,31 @@ export class FraudController {
|
|||||||
return { data: rule, message: 'Rule updated successfully' };
|
return { data: rule, message: 'Rule updated successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a fraud alert
|
||||||
|
*/
|
||||||
|
@Patch('alerts/:id/acknowledge')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||||
|
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
|
||||||
|
async acknowledgeAlert(@Param('id') id: string) {
|
||||||
|
const alert = await this.fraudService.acknowledgeAlert(id);
|
||||||
|
return { data: alert, message: 'Alert acknowledged' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block user via userId
|
||||||
|
*/
|
||||||
|
@Post('users/:userId/block')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||||
|
@ApiOperation({ summary: 'Block user by userId' })
|
||||||
|
async blockUserById(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() body: { reason?: string; durationMinutes?: number },
|
||||||
|
) {
|
||||||
|
await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60);
|
||||||
|
return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block user temporarily
|
* Block user temporarily
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { FraudService } from './fraud.service';
|
import { FraudService } from './fraud.service';
|
||||||
import { FraudController } from './fraud.controller';
|
import { FraudController } from './fraud.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule],
|
imports: [HttpModule, TypeOrmModule],
|
||||||
providers: [FraudService],
|
providers: [FraudService],
|
||||||
controllers: [FraudController],
|
controllers: [FraudController],
|
||||||
exports: [FraudService],
|
exports: [FraudService],
|
||||||
|
|||||||
@@ -164,6 +164,16 @@ export class FraudService {
|
|||||||
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
|
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a fraud alert
|
||||||
|
*/
|
||||||
|
async acknowledgeAlert(id: string) {
|
||||||
|
return this.prisma.fraudAlert.update({
|
||||||
|
where: { id },
|
||||||
|
data: { acknowledged: true, acknowledgedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all fraud alerts
|
* Get all fraud alerts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { LoyaltyService } from './loyalty.service';
|
import { LoyaltyService } from './loyalty.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
@@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard';
|
|||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
export class LoyaltyController {
|
export class LoyaltyController {
|
||||||
constructor(private service: LoyaltyService) {}
|
constructor(private service: LoyaltyService) {}
|
||||||
|
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
|
||||||
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
|
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
|
||||||
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
|
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
|
||||||
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
|
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
|
||||||
|
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
export class LoyaltyService {
|
export class LoyaltyService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) {
|
||||||
|
const { search, tier, page = '1', pageSize = '20' } = params;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(pageSize);
|
||||||
|
const where: any = {};
|
||||||
|
if (tier) where.tier = tier;
|
||||||
|
if (search) {
|
||||||
|
where.passenger = {
|
||||||
|
OR: [
|
||||||
|
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
|
||||||
|
{ user: { email: { contains: search, mode: 'insensitive' } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.loyaltyAccount.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: parseInt(pageSize),
|
||||||
|
orderBy: { pointsBalance: 'desc' },
|
||||||
|
include: { passenger: { include: { user: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.loyaltyAccount.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
items: items.map(a => ({
|
||||||
|
...a,
|
||||||
|
passenger: a.passenger ? {
|
||||||
|
id: a.passenger.id,
|
||||||
|
fullName: (a.passenger as any).user?.fullName ?? null,
|
||||||
|
email: (a.passenger as any).user?.email ?? null,
|
||||||
|
phone: (a.passenger as any).user?.phone ?? null,
|
||||||
|
} : null,
|
||||||
|
})),
|
||||||
|
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
|
||||||
|
};
|
||||||
|
}
|
||||||
async getAccount(passengerId: string) {
|
async getAccount(passengerId: string) {
|
||||||
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
|
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
|
||||||
if (!account) throw new NotFoundException('Loyalty account not found');
|
if (!account) throw new NotFoundException('Loyalty account not found');
|
||||||
@@ -40,4 +76,15 @@ export class LoyaltyService {
|
|||||||
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
|
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
|
||||||
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
|
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAccount(id: string) {
|
||||||
|
const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } });
|
||||||
|
if (!account) throw new NotFoundException('Loyalty account not found');
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }),
|
||||||
|
this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }),
|
||||||
|
this.prisma.loyaltyAccount.delete({ where: { id } }),
|
||||||
|
]);
|
||||||
|
return { deleted: true, accountId: id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { PackagesService } from './packages.service';
|
import { PackagesService } from './packages.service';
|
||||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
|
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
|
||||||
import { IamGuard } from '../../common/iam-adapter';
|
import { IamGuard } from '../../common/iam-adapter';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||||
@@ -63,6 +63,19 @@ export class PackagesController {
|
|||||||
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
|
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
|
||||||
|
listBookings(
|
||||||
|
@Query('packageId') packageId?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('my-bookings')
|
@Get('my-bookings')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -78,6 +91,30 @@ export class PackagesController {
|
|||||||
return this.service.getBookingByRef(ref);
|
return this.service.getBookingByRef(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@IsPublic()
|
||||||
|
@UseGuards(OptionalJwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
|
||||||
|
book(@Body() dto: BookPackageDto, @Request() req: any) {
|
||||||
|
return this.service.book(dto, req.user?.passengerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/booking-context')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
|
||||||
|
@ApiQuery({ name: 'tierId', required: true })
|
||||||
|
@ApiQuery({ name: 'adultCount', required: true })
|
||||||
|
@ApiQuery({ name: 'childCount', required: false })
|
||||||
|
getBookingContext(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('tierId') tierId: string,
|
||||||
|
@Query('adultCount') adultCount: string,
|
||||||
|
@Query('childCount') childCount?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@ApiOperation({ summary: 'Get package details' })
|
@ApiOperation({ summary: 'Get package details' })
|
||||||
@@ -149,12 +186,4 @@ export class PackagesController {
|
|||||||
return this.service.deleteTier(tierId);
|
return this.service.deleteTier(tierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('book')
|
|
||||||
@IsPublic()
|
|
||||||
@UseGuards(OptionalJwtGuard)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
|
|
||||||
book(@Body() dto: BookPackageDto, @Request() req: any) {
|
|
||||||
return this.service.book(dto, req.user?.passengerId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
|
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -93,6 +93,12 @@ export class BookPackagePassengerDto {
|
|||||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PackageBookingContextDto {
|
||||||
|
@ApiProperty() @IsUUID() tierId: string;
|
||||||
|
@ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number;
|
||||||
|
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class BookPackageDto {
|
export class BookPackageDto {
|
||||||
@ApiProperty() @IsUUID() packageId: string;
|
@ApiProperty() @IsUUID() packageId: string;
|
||||||
@ApiProperty() @IsUUID() priceTierId: string;
|
@ApiProperty() @IsUUID() priceTierId: string;
|
||||||
@@ -112,4 +118,10 @@ export class BookPackageDto {
|
|||||||
@ApiProperty({ type: [BookPackagePassengerDto] })
|
@ApiProperty({ type: [BookPackagePassengerDto] })
|
||||||
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
|
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
|
||||||
passengers: BookPackagePassengerDto[];
|
passengers: BookPackagePassengerDto[];
|
||||||
|
|
||||||
|
/** Number of adult passengers (≥5 years). Derived from passengers array if omitted. */
|
||||||
|
@ApiPropertyOptional({ example: 2 }) @IsOptional() @IsInt() @Min(1) adultCount?: number;
|
||||||
|
|
||||||
|
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
|
||||||
|
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module';
|
|||||||
import { PackagesController } from './packages.controller';
|
import { PackagesController } from './packages.controller';
|
||||||
import { PackagesService } from './packages.service';
|
import { PackagesService } from './packages.service';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, CurrencyModule],
|
imports: [PrismaModule, CurrencyModule, BookingsModule],
|
||||||
controllers: [PackagesController],
|
controllers: [PackagesController],
|
||||||
providers: [PackagesService],
|
providers: [PackagesService],
|
||||||
exports: [PackagesService],
|
exports: [PackagesService],
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||||
|
|
||||||
|
/** Package-specific fare rules */
|
||||||
|
const PKG_MAX_ADULTS = 5;
|
||||||
|
const PKG_MAX_CHILDREN = 2;
|
||||||
|
const PKG_CHILD_FARE_RATIO = 0.1;
|
||||||
|
|
||||||
|
function calculatePackageFareBreakdown(
|
||||||
|
priceMinor: number,
|
||||||
|
isRoundTrip: boolean,
|
||||||
|
adultCount: number,
|
||||||
|
childCount: number,
|
||||||
|
) {
|
||||||
|
const multiplier = isRoundTrip ? 2 : 1;
|
||||||
|
const adultFareMinor = priceMinor * multiplier;
|
||||||
|
const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO);
|
||||||
|
const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor;
|
||||||
|
return { adultFareMinor, childFareMinor, totalMinor, multiplier };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAge(dateOfBirth: string | Date): number {
|
||||||
|
const today = new Date();
|
||||||
|
const dob = new Date(dateOfBirth);
|
||||||
|
let age = today.getFullYear() - dob.getFullYear();
|
||||||
|
if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
return 'PKG-' + Array.from({ length: 6 }, () =>
|
return 'PKG-' + Array.from({ length: 6 }, () =>
|
||||||
@@ -15,8 +43,102 @@ export class PackagesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly currencyService: CurrencyService,
|
private readonly currencyService: CurrencyService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly guestBookingService: GuestBookingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
|
||||||
|
const pkg = await this.prisma.travelPackage.findUnique({
|
||||||
|
where: { id: packageId },
|
||||||
|
include: {
|
||||||
|
priceTiers: true,
|
||||||
|
outboundSchedule: {
|
||||||
|
include: {
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
|
||||||
|
const tier = pkg.priceTiers.find((t) => t.id === tierId);
|
||||||
|
if (!tier) throw new NotFoundException('Price tier not found');
|
||||||
|
|
||||||
|
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
||||||
|
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
||||||
|
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
||||||
|
|
||||||
|
const passengerCount = adultCount + childCount;
|
||||||
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||||
|
if (passengerCount > remaining)
|
||||||
|
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
|
||||||
|
|
||||||
|
const isRoundTrip = !!pkg.returnScheduleId;
|
||||||
|
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
||||||
|
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
|
||||||
|
let seatClassId: string | null = null;
|
||||||
|
let coachTypeId: string | null = null;
|
||||||
|
for (const a of pkg.outboundSchedule.coachAssignments) {
|
||||||
|
const sc = a.coach.coachType?.seatClasses?.find(
|
||||||
|
(s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) ||
|
||||||
|
tier.seatType.toLowerCase().includes(s.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
|
||||||
|
}
|
||||||
|
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
|
||||||
|
const first = pkg.outboundSchedule.coachAssignments[0];
|
||||||
|
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageId: pkg.id,
|
||||||
|
packageName: pkg.name,
|
||||||
|
priceTierId: tier.id,
|
||||||
|
tierLabel: tier.label,
|
||||||
|
seatType: tier.seatType,
|
||||||
|
seatClassId,
|
||||||
|
coachTypeId,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
passengerCount,
|
||||||
|
isRoundTrip,
|
||||||
|
pricePerAdultMinor: adultFareMinor,
|
||||||
|
pricePerChildMinor: childFareMinor,
|
||||||
|
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
||||||
|
maxAdults: PKG_MAX_ADULTS,
|
||||||
|
maxChildren: PKG_MAX_CHILDREN,
|
||||||
|
totalMinor,
|
||||||
|
currency: tier.currency,
|
||||||
|
remainingSeats: remaining,
|
||||||
|
outboundSchedule: {
|
||||||
|
scheduleId: pkg.outboundScheduleId,
|
||||||
|
originStationId: pkg.outboundSchedule.originStationId,
|
||||||
|
destinationStationId: pkg.outboundSchedule.destinationStationId,
|
||||||
|
departureAt: pkg.outboundSchedule.departureAt,
|
||||||
|
arrivalAt: pkg.outboundSchedule.arrivalAt,
|
||||||
|
originStation: pkg.outboundSchedule.originStation,
|
||||||
|
destinationStation: pkg.outboundSchedule.destinationStation,
|
||||||
|
},
|
||||||
|
returnSchedule: pkg.returnSchedule ? {
|
||||||
|
scheduleId: pkg.returnScheduleId,
|
||||||
|
originStationId: pkg.returnSchedule.originStationId,
|
||||||
|
destinationStationId: pkg.returnSchedule.destinationStationId,
|
||||||
|
departureAt: pkg.returnSchedule.departureAt,
|
||||||
|
arrivalAt: pkg.returnSchedule.arrivalAt,
|
||||||
|
originStation: pkg.returnSchedule.originStation,
|
||||||
|
destinationStation: pkg.returnSchedule.destinationStation,
|
||||||
|
} : null,
|
||||||
|
includedServices: pkg.includedServices,
|
||||||
|
busTransferIncluded: pkg.busTransferIncluded,
|
||||||
|
busTransferRoute: pkg.busTransferRoute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async createInquiry(dto: CreateInquiryDto) {
|
async createInquiry(dto: CreateInquiryDto) {
|
||||||
return this.prisma.packageInquiry.create({
|
return this.prisma.packageInquiry.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -74,7 +196,7 @@ export class PackagesService {
|
|||||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||||
},
|
},
|
||||||
orderBy: { validFrom: 'asc' },
|
orderBy: { validFrom: 'asc' },
|
||||||
});
|
}).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
@@ -82,12 +204,19 @@ export class PackagesService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
priceTiers: true,
|
priceTiers: true,
|
||||||
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
outboundSchedule: {
|
||||||
|
include: {
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
train: true,
|
||||||
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!pkg) throw new NotFoundException('Package not found');
|
if (!pkg) throw new NotFoundException('Package not found');
|
||||||
return pkg;
|
return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' };
|
||||||
}
|
}
|
||||||
|
|
||||||
create(dto: CreatePackageDto) {
|
create(dto: CreatePackageDto) {
|
||||||
@@ -209,13 +338,30 @@ export class PackagesService {
|
|||||||
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
||||||
if (!tier) throw new NotFoundException('Price tier not found');
|
if (!tier) throw new NotFoundException('Price tier not found');
|
||||||
|
|
||||||
const passengerCount = dto.passengers.length;
|
// Derive adult/child counts from the passengers array (dateOfBirth-based)
|
||||||
|
let adultCount = 0, childCount = 0;
|
||||||
|
for (const p of dto.passengers) {
|
||||||
|
if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++;
|
||||||
|
else adultCount++;
|
||||||
|
}
|
||||||
|
// Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger)
|
||||||
|
if (dto.adultCount !== undefined) adultCount = dto.adultCount;
|
||||||
|
if (dto.childCount !== undefined) childCount = dto.childCount;
|
||||||
|
|
||||||
|
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
|
||||||
|
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
|
||||||
|
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
|
||||||
|
|
||||||
|
const passengerCount = adultCount + childCount;
|
||||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||||
if (passengerCount > remaining) {
|
if (passengerCount > remaining) {
|
||||||
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
|
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalMinor = tier.priceMinor * passengerCount;
|
const isRoundTrip = !!pkg.returnScheduleId;
|
||||||
|
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
|
||||||
|
tier.priceMinor, isRoundTrip, adultCount, childCount,
|
||||||
|
);
|
||||||
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
|
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
|
||||||
const displayTotalMinor =
|
const displayTotalMinor =
|
||||||
displayCurrency !== Currency.ETB
|
displayCurrency !== Currency.ETB
|
||||||
@@ -266,7 +412,21 @@ export class PackagesService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return booking;
|
return {
|
||||||
|
...booking,
|
||||||
|
fareBreakdown: {
|
||||||
|
isRoundTrip,
|
||||||
|
adultCount,
|
||||||
|
adultFareMinor,
|
||||||
|
childCount,
|
||||||
|
childFareMinor,
|
||||||
|
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
|
||||||
|
totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getMyBookings(passengerId: string) {
|
getMyBookings(passengerId: string) {
|
||||||
@@ -296,6 +456,29 @@ export class PackagesService {
|
|||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
|
||||||
|
const where: any = {};
|
||||||
|
if (packageId) where.packageId = packageId;
|
||||||
|
if (status) where.status = status;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.packageBooking.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
package: { select: { id: true, name: true, code: true } },
|
||||||
|
priceTier: { select: { id: true, label: true, seatType: true } },
|
||||||
|
passengers: true,
|
||||||
|
paymentIntent: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.packageBooking.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
async listAll(page = 1, pageSize = 20) {
|
async listAll(page = 1, pageSize = 20) {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
|
|||||||
@@ -433,39 +433,49 @@ export class PassengersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deletePassenger(id: string) {
|
async deletePassenger(id: string) {
|
||||||
const passenger = await this.prisma.passenger.findUnique({
|
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
|
||||||
|
let passenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: { user: true },
|
||||||
user: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
|
||||||
|
if (!passenger) {
|
||||||
|
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
|
||||||
|
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
|
||||||
|
passenger = await this.prisma.passenger.findUnique({
|
||||||
|
where: { id: profile.passengerId },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passengerId = passenger.id;
|
||||||
|
|
||||||
// Check usage before allowing deletion
|
// Check usage before allowing deletion
|
||||||
const usage = await this.checkPassengerUsage(id);
|
const usage = await this.checkPassengerUsage(passengerId);
|
||||||
if (usage.isInUse && usage.constraints) {
|
if (usage.isInUse && usage.constraints) {
|
||||||
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
|
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
|
||||||
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }),
|
||||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
|
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }),
|
||||||
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
|
this.prisma.walletAccount.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
|
this.prisma.notification.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
|
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
|
this.prisma.savedRoute.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
|
this.prisma.packageBooking.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
|
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }),
|
||||||
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
|
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }),
|
||||||
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
|
this.prisma.booking.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
|
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }),
|
||||||
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
|
this.prisma.journey.deleteMany({ where: { passengerId } }),
|
||||||
this.prisma.passenger.delete({ where: { id } }),
|
this.prisma.passenger.delete({ where: { id: passengerId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { deleted: true, passengerId: id };
|
return { deleted: true, passengerId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPassengerUsage(id: string) {
|
async checkPassengerUsage(id: string) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
@@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
|||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private service: PaymentsService) {}
|
constructor(private service: PaymentsService) {}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.admin])
|
||||||
|
@ApiBearerAuth("IAM-auth")
|
||||||
|
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
|
||||||
|
deletePayment(@Param("id") id: string) {
|
||||||
|
return this.service.deletePayment(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("all")
|
@Get("all")
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export class PaymentsService {
|
|||||||
private currencyService: CurrencyService,
|
private currencyService: CurrencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async deletePayment(id: string) {
|
||||||
|
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
|
||||||
|
if (!intent) throw new NotFoundException('Payment intent not found');
|
||||||
|
await this.prisma.paymentIntent.delete({ where: { id } });
|
||||||
|
return { deleted: true, id };
|
||||||
|
}
|
||||||
|
|
||||||
async getAll(filters: {
|
async getAll(filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export class CreateScheduleDto {
|
|||||||
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||||
plannedTimes: PlannedStopTimeDto[];
|
plannedTimes?: PlannedStopTimeDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateScheduleDto {
|
export class UpdateScheduleDto {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||||
if (missingSeqs.length > 0) {
|
if (missingSeqs.length > 0) {
|
||||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||||
|
|
||||||
@ApiTags('Search')
|
@ApiTags('Search')
|
||||||
@Controller('search')
|
@Controller('search')
|
||||||
@@ -66,4 +66,29 @@ Nationality-Based:
|
|||||||
getFareQuote(@Body() dto: FareQuoteDto) {
|
getFareQuote(@Body() dto: FareQuoteDto) {
|
||||||
return this.service.getFareQuote(dto);
|
return this.service.getFareQuote(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('fare-breakdown')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Per-passenger fare breakdown for booking review page',
|
||||||
|
description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class.
|
||||||
|
|
||||||
|
- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs)
|
||||||
|
- First CHILD in the list travels free (pays only premium + insurance fees)
|
||||||
|
- Each passenger can have a different seat class and nationality
|
||||||
|
- Returns per-passenger lines plus subtotal, discount, and grand total
|
||||||
|
|
||||||
|
**passengers** must be a URL-encoded JSON array, e.g.:
|
||||||
|
\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``,
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
||||||
|
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
||||||
|
@ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' })
|
||||||
|
@ApiQuery({ name: 'promoCode', required: false })
|
||||||
|
@ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] })
|
||||||
|
@ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||||
|
getFareBreakdown(@Query() dto: FareBreakdownRequestDto) {
|
||||||
|
return this.service.getFareBreakdown(dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,43 @@ export class CoachTypeOptionClass {
|
|||||||
@ApiProperty({ example: 35000 }) baseFareMinor: number;
|
@ApiProperty({ example: 35000 }) baseFareMinor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FareBreakdownPassengerDto {
|
||||||
|
@ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' })
|
||||||
|
@IsString() passengerName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' })
|
||||||
|
@IsDateString() dateOfBirth: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' })
|
||||||
|
@IsString() seatClassId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' })
|
||||||
|
@IsOptional() @IsString() nationality?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FareBreakdownRequestDto {
|
||||||
|
@ApiProperty({ example: 'schedule-uuid' })
|
||||||
|
@IsString() scheduleId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]',
|
||||||
|
description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }',
|
||||||
|
})
|
||||||
|
@IsString() passengers: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'WEEKEND15' })
|
||||||
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'USD', enum: Currency })
|
||||||
|
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
|
}
|
||||||
|
|
||||||
export class CoachTypeOption {
|
export class CoachTypeOption {
|
||||||
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
||||||
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
@@ -477,6 +477,124 @@ export class SearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFareBreakdown(dto: FareBreakdownRequestDto) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: dto.scheduleId },
|
||||||
|
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
|
||||||
|
|
||||||
|
let parsedPassengers: FareBreakdownPassengerDto[];
|
||||||
|
try {
|
||||||
|
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException('passengers must be a valid JSON array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorise passengers by age
|
||||||
|
const categorised = parsedPassengers.map(p => {
|
||||||
|
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
|
||||||
|
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||||
|
return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears };
|
||||||
|
});
|
||||||
|
|
||||||
|
const adultCount = categorised.filter(p => p.category === 'ADULT').length;
|
||||||
|
const childCount = categorised.filter(p => p.category === 'CHILD').length;
|
||||||
|
|
||||||
|
// Ask the fare engine for the authoritative free-child count using the full group
|
||||||
|
// Use the first passenger's seatClassId as a representative — freeChildrenCount
|
||||||
|
// depends only on adultCount/childCount, not on seat class.
|
||||||
|
const groupFare = await this.fareEngine.calculate({
|
||||||
|
routeId: schedule.routeId!,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
seatClassId: categorised[0].seatClassId,
|
||||||
|
nationality: categorised[0].nationality,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
});
|
||||||
|
const freeChildrenAllowed = groupFare.freeChildrenCount;
|
||||||
|
|
||||||
|
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
|
||||||
|
let freeChildrenUsed = 0;
|
||||||
|
const passengerLines = await Promise.all(
|
||||||
|
categorised.map(async (p) => {
|
||||||
|
const fare = await this.fareEngine.calculate({
|
||||||
|
routeId: schedule.routeId!,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
seatClassId: p.seatClassId,
|
||||||
|
nationality: p.nationality,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
adultCount: 1,
|
||||||
|
childCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
|
||||||
|
if (isFree) freeChildrenUsed++;
|
||||||
|
|
||||||
|
const fareMinor = isFree
|
||||||
|
? fare.premiumPerPassenger + fare.insurancePerPassenger
|
||||||
|
: fare.farePerPassengerMinor;
|
||||||
|
const displayFareMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency)
|
||||||
|
: fareMinor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
passengerName: p.passengerName,
|
||||||
|
dateOfBirth: p.dateOfBirth,
|
||||||
|
category: p.category,
|
||||||
|
ageYears: Math.floor(p.ageYears),
|
||||||
|
seatClassId: fare.seatClassId,
|
||||||
|
seatClassName: fare.seatClassName,
|
||||||
|
nationality: p.nationality ?? null,
|
||||||
|
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||||
|
premiumMinor: fare.premiumPerPassenger,
|
||||||
|
insuranceFeeMinor: fare.insurancePerPassenger,
|
||||||
|
fareMinor,
|
||||||
|
isFree,
|
||||||
|
displayCurrency,
|
||||||
|
displayFareMinor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
|
||||||
|
|
||||||
|
let discountMinor = 0;
|
||||||
|
if (dto.promoCode) {
|
||||||
|
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||||
|
if (promo?.active && promo.validUntil > now) {
|
||||||
|
discountMinor = promo.percentOff
|
||||||
|
? Math.round(subtotalMinor * promo.percentOff / 100)
|
||||||
|
: (promo.amountOffMinor ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinor = subtotalMinor - discountMinor;
|
||||||
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
|
: totalMinor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
passengers: passengerLines,
|
||||||
|
subtotalMinor,
|
||||||
|
discountMinor,
|
||||||
|
totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async calculateFaresForSegment(
|
private async calculateFaresForSegment(
|
||||||
schedule: ScheduleWithIncludes,
|
schedule: ScheduleWithIncludes,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatClassesService {
|
export class SeatClassesService {
|
||||||
@@ -45,8 +46,24 @@ export class SeatClassesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteSeatClass(id: string) {
|
async deleteSeatClass(id: string) {
|
||||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
const sc = await this.prisma.seatClass.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||||
|
|
||||||
|
const totalFareRules =
|
||||||
|
(sc as any)._count.fareRules +
|
||||||
|
(sc as any)._count.routeFareRules +
|
||||||
|
(sc as any)._count.segmentFares;
|
||||||
|
|
||||||
|
if (totalFareRules > 0)
|
||||||
|
throw new DeleteOperationException('Seat Class', sc.name, [
|
||||||
|
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
|
||||||
|
]);
|
||||||
|
|
||||||
return this.prisma.seatClass.delete({ where: { id } });
|
return this.prisma.seatClass.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter";
|
|||||||
export class SeatsController {
|
export class SeatsController {
|
||||||
constructor(private service: SeatsService) {}
|
constructor(private service: SeatsService) {}
|
||||||
|
|
||||||
|
// ── Coach Availability ────────────────────────────────────────────────────
|
||||||
|
@Get('coaches/:scheduleId')
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'List coaches with remaining seat counts for a schedule',
|
||||||
|
description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' })
|
||||||
|
@ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Coaches with seat availability counts' })
|
||||||
|
getCoachesWithAvailability(
|
||||||
|
@Param('scheduleId') scheduleId: string,
|
||||||
|
@Query('originStationId') originStationId?: string,
|
||||||
|
@Query('destinationStationId') destinationStationId?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||||
@Get("seatmap/:scheduleId")
|
@Get("seatmap/:scheduleId")
|
||||||
@SetMetadata('isPublic', true)
|
@SetMetadata('isPublic', true)
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ export class SeatsService {
|
|||||||
const resolvedBedPosition = isBedCoach
|
const resolvedBedPosition = isBedCoach
|
||||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||||
: s.bedPosition;
|
: s.bedPosition;
|
||||||
|
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
seatNumber: s.seatNumber,
|
seatNumber: s.seatNumber,
|
||||||
label: s.seatNumber,
|
label: s.seatNumber,
|
||||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
status: effectiveStatus,
|
||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
row: s.row,
|
row: s.row,
|
||||||
col: s.col,
|
col: s.col,
|
||||||
@@ -245,11 +246,16 @@ export class SeatsService {
|
|||||||
holdFrom === undefined || holdTo === undefined ||
|
holdFrom === undefined || holdTo === undefined ||
|
||||||
(holdFrom < reqTo && reqFrom < holdTo);
|
(holdFrom < reqTo && reqFrom < holdTo);
|
||||||
|
|
||||||
if (!legsOverlap) continue;
|
|
||||||
|
|
||||||
// Check direction conflict
|
// Check direction conflict
|
||||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
||||||
if (!directionsConflict) continue;
|
|
||||||
|
if (!legsOverlap || !directionsConflict) {
|
||||||
|
// This hold does not conflict with the requested leg/direction.
|
||||||
|
// Explicitly mark AVAILABLE so the DB's HELD status (set by the
|
||||||
|
// opposing-direction hold) does not bleed through via the fallback.
|
||||||
|
if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
statusMap.set(seatId, 'HELD');
|
statusMap.set(seatId, 'HELD');
|
||||||
}
|
}
|
||||||
@@ -367,7 +373,24 @@ export class SeatsService {
|
|||||||
where: { scheduleId: dto.scheduleId },
|
where: { scheduleId: dto.scheduleId },
|
||||||
select: { stationId: true, sequence: true },
|
select: { stationId: true, sequence: true },
|
||||||
});
|
});
|
||||||
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
|
|
||||||
|
// When no stop times exist, fall back to the schedule's own origin/destination
|
||||||
|
// with synthetic sequences so the hold can still be created.
|
||||||
|
let effectiveStopTimes = stopTimes;
|
||||||
|
if (stopTimes.length === 0) {
|
||||||
|
const sched = await tx.trainSchedule.findUnique({
|
||||||
|
where: { id: dto.scheduleId },
|
||||||
|
select: { originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (sched) {
|
||||||
|
effectiveStopTimes = [
|
||||||
|
{ stationId: sched.originStationId, sequence: 0 },
|
||||||
|
{ stationId: sched.destinationStationId, sequence: 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||||
const reqFrom = seqOf(dto.originStationId);
|
const reqFrom = seqOf(dto.originStationId);
|
||||||
const reqTo = seqOf(dto.destinationStationId);
|
const reqTo = seqOf(dto.destinationStationId);
|
||||||
|
|
||||||
@@ -604,6 +627,56 @@ export class SeatsService {
|
|||||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
select: { originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
const assignments = await this.prisma.coachAssignment.findMany({
|
||||||
|
where: { scheduleId },
|
||||||
|
include: {
|
||||||
|
coach: {
|
||||||
|
include: {
|
||||||
|
seats: { select: { id: true, status: true, seatNumber: true } },
|
||||||
|
coachType: { include: { seatClasses: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { positionNumber: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||||
|
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||||
|
scheduleId,
|
||||||
|
allSeatIds,
|
||||||
|
originStationId ?? schedule.originStationId,
|
||||||
|
destinationStationId ?? schedule.destinationStationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return assignments.map(a => {
|
||||||
|
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||||
|
const totalSeats = seats.length;
|
||||||
|
const unavailable = seats.filter(s => {
|
||||||
|
const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||||
|
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
coachId: a.coach.id,
|
||||||
|
coachNumber: a.coach.number,
|
||||||
|
positionNumber: a.positionNumber,
|
||||||
|
coachTypeName: a.coach.coachType?.name ?? '',
|
||||||
|
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
|
||||||
|
totalSeats,
|
||||||
|
availableSeats: totalSeats - unavailable,
|
||||||
|
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length,
|
||||||
|
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||||
const seats = await this.prisma.seat.findMany({
|
const seats = await this.prisma.seat.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -794,11 +867,21 @@ export class SeatsService {
|
|||||||
if (expired.length === 0) return;
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
||||||
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
|
|
||||||
await this.prisma.seat.updateMany({
|
// Only reset seats that have no remaining active holds
|
||||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
const stillHeld = await this.prisma.seatHold.findMany({
|
||||||
data: { status: 'AVAILABLE' },
|
where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
|
||||||
|
select: { seatIds: true },
|
||||||
});
|
});
|
||||||
|
const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
|
||||||
|
const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
|
||||||
|
|
||||||
|
if (toRelease.length > 0) {
|
||||||
|
await this.prisma.seat.updateMany({
|
||||||
|
where: { id: { in: toRelease }, status: 'HELD' },
|
||||||
|
data: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
/**
|
|
||||||
* SEGMENT-BASED SEAT RESERVATION EXAMPLE
|
|
||||||
*
|
|
||||||
* Demonstrates the complete flow for booking Addis Ababa → Dire Dawa
|
|
||||||
* on the Addis Ababa → Djibouti route with segment-based seat management.
|
|
||||||
*
|
|
||||||
* Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6)
|
|
||||||
* Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function exampleBookingFlow() {
|
|
||||||
console.log('=== SEGMENT-BASED BOOKING FLOW ===\n');
|
|
||||||
|
|
||||||
const scheduleId = 'schedule_add_dji_001';
|
|
||||||
const passengerId = 'passenger_kelemu';
|
|
||||||
const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b'];
|
|
||||||
const originStationId = 'st_ADD';
|
|
||||||
const destinationStationId = 'st_DRE';
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('1. Checking seat availability...');
|
|
||||||
const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId);
|
|
||||||
console.log('Journey segments:', segments.map(s => `${s.fromName} → ${s.toName}`));
|
|
||||||
|
|
||||||
console.log('\n2. Holding seats...');
|
|
||||||
const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId);
|
|
||||||
console.log('Hold created:', holdResult);
|
|
||||||
|
|
||||||
console.log('\n3. Processing payment...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
||||||
|
|
||||||
console.log('\n4. Confirming booking...');
|
|
||||||
const bookingId = 'booking_' + Date.now();
|
|
||||||
const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments);
|
|
||||||
console.log('Booking confirmed:', confirmResult);
|
|
||||||
|
|
||||||
console.log('\n5. Simulating trip progress...');
|
|
||||||
await simulateTripProgress(scheduleId, segments);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Booking flow error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) {
|
|
||||||
const stopTimes = await prisma.tripStopTime.findMany({
|
|
||||||
where: { scheduleId },
|
|
||||||
include: { station: true },
|
|
||||||
orderBy: { sequence: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const originStop = stopTimes.find(st => st.stationId === originStationId);
|
|
||||||
const destinationStop = stopTimes.find(st => st.stationId === destinationStationId);
|
|
||||||
|
|
||||||
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) {
|
|
||||||
throw new Error('Invalid origin/destination');
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = [];
|
|
||||||
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
|
|
||||||
const fromStop = stopTimes.find(st => st.sequence === i);
|
|
||||||
const toStop = stopTimes.find(st => st.sequence === i + 1);
|
|
||||||
if (fromStop && toStop) {
|
|
||||||
segments.push({
|
|
||||||
fromStationId: fromStop.stationId,
|
|
||||||
toStationId: toStop.stationId,
|
|
||||||
fromSequence: fromStop.sequence,
|
|
||||||
toSequence: toStop.sequence,
|
|
||||||
fromName: fromStop.station.name,
|
|
||||||
toName: toStop.station.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
|
|
||||||
return prisma.$transaction(async (tx) => {
|
|
||||||
console.log(' → Starting seat hold transaction...');
|
|
||||||
|
|
||||||
const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } });
|
|
||||||
if (seats.length !== seatIds.length) throw new Error('Some seats not found');
|
|
||||||
|
|
||||||
for (const seat of seats) {
|
|
||||||
if (seat.status !== 'AVAILABLE') {
|
|
||||||
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
|
||||||
const seatHold = await tx.seatHold.create({
|
|
||||||
data: { scheduleId, seatIds, passengerId, expiresAt },
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
|
||||||
|
|
||||||
console.log(' → Seats held successfully');
|
|
||||||
return { holdId: seatHold.id, expiresAt, seats: seatIds.length };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) {
|
|
||||||
return prisma.$transaction(async (tx) => {
|
|
||||||
console.log(' → Starting booking confirmation transaction...');
|
|
||||||
|
|
||||||
const hold = await tx.seatHold.findUnique({ where: { id: holdId } });
|
|
||||||
if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found');
|
|
||||||
|
|
||||||
const booking = await tx.booking.create({
|
|
||||||
data: {
|
|
||||||
id: bookingId,
|
|
||||||
bookingRef: 'BK' + Date.now().toString().slice(-6),
|
|
||||||
passengerId: hold.passengerId,
|
|
||||||
scheduleId: hold.scheduleId,
|
|
||||||
status: 'CONFIRMED',
|
|
||||||
totalMinor: 45000,
|
|
||||||
currency: 'ETB',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const journey = await tx.journey.create({
|
|
||||||
data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const seatId of hold.seatIds) {
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
await tx.journeySegment.create({
|
|
||||||
data: {
|
|
||||||
journeyId: journey.id,
|
|
||||||
scheduleId: hold.scheduleId,
|
|
||||||
segmentOrder: i + 1,
|
|
||||||
seatId,
|
|
||||||
departureStationId: segments[i].fromStationId,
|
|
||||||
arrivalStationId: segments[i].toStationId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const seatId of hold.seatIds) {
|
|
||||||
await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
|
|
||||||
await tx.seatHold.delete({ where: { id: holdId } });
|
|
||||||
|
|
||||||
console.log(' → Booking confirmed successfully');
|
|
||||||
return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) {
|
|
||||||
console.log(' → Simulating trip progress...');
|
|
||||||
|
|
||||||
for (const segment of bookedSegments) {
|
|
||||||
console.log(` → Train approaching ${segment.toName}...`);
|
|
||||||
|
|
||||||
await prisma.tripLiveStatus.upsert({
|
|
||||||
where: { scheduleId },
|
|
||||||
update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) },
|
|
||||||
create: {
|
|
||||||
scheduleId,
|
|
||||||
state: 'EN_ROUTE',
|
|
||||||
currentLocationLabel: segment.toName,
|
|
||||||
progressPercent: Math.round((segment.toSequence / 4) * 100),
|
|
||||||
delayMinutes: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (segment.toName === 'Dire Dawa') {
|
|
||||||
console.log(' → Passengers reached destination, releasing seats...');
|
|
||||||
await releaseSeatsAtStation(scheduleId, segment.toStationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function releaseSeatsAtStation(scheduleId: string, stationId: string) {
|
|
||||||
return prisma.$transaction(async (tx) => {
|
|
||||||
const completedSegments = await tx.journeySegment.findMany({
|
|
||||||
where: { scheduleId, arrivalStationId: stationId },
|
|
||||||
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const seatsToRelease: string[] = [];
|
|
||||||
|
|
||||||
for (const segment of completedSegments) {
|
|
||||||
const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId);
|
|
||||||
const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder));
|
|
||||||
if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seatsToRelease.length > 0) {
|
|
||||||
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
|
|
||||||
console.log(` → Released ${seatsToRelease.length} seats at station`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return seatsToRelease;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) {
|
|
||||||
const activeHolds = await tx.seatHold.findMany({
|
|
||||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeBookings = await tx.journeySegment.findMany({
|
|
||||||
where: {
|
|
||||||
scheduleId,
|
|
||||||
seatId,
|
|
||||||
journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...activeHolds, ...activeBookings];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
exampleBookingFlow()
|
|
||||||
.then(() => console.log('\n=== EXAMPLES COMPLETED ==='))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => prisma.$disconnect());
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
exampleBookingFlow,
|
|
||||||
getJourneySegments,
|
|
||||||
holdSeatsTransaction,
|
|
||||||
confirmBookingTransaction,
|
|
||||||
simulateTripProgress,
|
|
||||||
releaseSeatsAtStation,
|
|
||||||
checkOverlappingReservations,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Controller, Get, Post, Put, Delete,
|
||||||
|
Param, Body, Query, UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { IamGuard } from '../../common/iam-adapter';
|
||||||
|
import { Roles } from '../../common/roles.decorator';
|
||||||
|
import { SegmentFareService } from './segment-fare.service';
|
||||||
|
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||||
|
|
||||||
|
@ApiTags('Admin – Segment Fares')
|
||||||
|
@Controller('admin/segment-fares')
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@Roles('ADMIN', 'SUPERVISOR')
|
||||||
|
export class SegmentFareController {
|
||||||
|
constructor(private readonly service: SegmentFareService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' })
|
||||||
|
@ApiQuery({ name: 'routeId', required: false })
|
||||||
|
findAll(@Query('routeId') routeId?: string) {
|
||||||
|
return this.service.findAll(routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a single segment fare rule' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||||
|
create(@Body() dto: CreateSegmentFareDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, IsOptional, IsDateString, IsIn, Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSegmentFareDto {
|
||||||
|
@ApiProperty({ example: 'route-uuid' })
|
||||||
|
@IsString()
|
||||||
|
routeId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
@IsInt() @Min(0)
|
||||||
|
originStopSequence: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 5 })
|
||||||
|
@IsInt() @Min(1)
|
||||||
|
destinationStopSequence: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'seat-class-uuid' })
|
||||||
|
@IsString()
|
||||||
|
seatClassId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' })
|
||||||
|
@IsInt() @Min(0)
|
||||||
|
baseFareMinor: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['LOCAL', 'INTERNATIONAL'])
|
||||||
|
nationality?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ETB', default: 'ETB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
|
||||||
|
@IsDateString()
|
||||||
|
validFrom: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSegmentFareDto {
|
||||||
|
@ApiPropertyOptional({ example: 40000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt() @Min(0)
|
||||||
|
baseFareMinor?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ETB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SegmentFareService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findAll(routeId?: string) {
|
||||||
|
return this.prisma.segmentFareRule.findMany({
|
||||||
|
where: routeId ? { routeId } : undefined,
|
||||||
|
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||||
|
orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const rule = await this.prisma.segmentFareRule.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(dto: CreateSegmentFareDto) {
|
||||||
|
return this.prisma.segmentFareRule.create({
|
||||||
|
data: {
|
||||||
|
routeId: dto.routeId,
|
||||||
|
originStopSequence: dto.originStopSequence,
|
||||||
|
destinationStopSequence: dto.destinationStopSequence,
|
||||||
|
seatClassId: dto.seatClassId,
|
||||||
|
baseFareMinor: dto.baseFareMinor,
|
||||||
|
nationality: dto.nationality ?? null,
|
||||||
|
currency: dto.currency ?? 'ETB',
|
||||||
|
validFrom: new Date(dto.validFrom),
|
||||||
|
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
|
||||||
|
},
|
||||||
|
include: { seatClass: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateSegmentFareDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.segmentFareRule.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
|
||||||
|
...(dto.currency !== undefined && { currency: dto.currency }),
|
||||||
|
...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }),
|
||||||
|
...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }),
|
||||||
|
},
|
||||||
|
include: { seatClass: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.segmentFareRule.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service';
|
|||||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||||
import { TripProgressService } from './trip-progress.service';
|
import { TripProgressService } from './trip-progress.service';
|
||||||
import { SegmentSeatsController } from './segments.controller';
|
import { SegmentSeatsController } from './segments.controller';
|
||||||
|
import { SegmentFareController } from './segment-fare.controller';
|
||||||
|
import { SegmentFareService } from './segment-fare.service';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [SegmentSeatsController],
|
controllers: [SegmentSeatsController, SegmentFareController],
|
||||||
providers: [
|
providers: [
|
||||||
SegmentsService,
|
SegmentsService,
|
||||||
EnhancedSeatsService,
|
EnhancedSeatsService,
|
||||||
TripProgressService,
|
TripProgressService,
|
||||||
PrismaService
|
SegmentFareService,
|
||||||
|
PrismaService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
SegmentsService,
|
SegmentsService,
|
||||||
EnhancedSeatsService,
|
EnhancedSeatsService,
|
||||||
TripProgressService
|
TripProgressService,
|
||||||
]
|
SegmentFareService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class SegmentsModule {}
|
export class SegmentsModule {}
|
||||||
@@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { CreateStationDto } from './stations.dto';
|
import { CreateStationDto } from './stations.dto';
|
||||||
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
|
|
||||||
interface StationFilters {
|
interface StationFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -96,7 +97,35 @@ export class StationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
const station = await this.findOne(id);
|
const station = await this.prisma.station.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: { select: { stopTimes: true } },
|
||||||
|
originSchedules: { take: 1, select: { id: true } },
|
||||||
|
destinationSchedules: { take: 1, select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!station) throw new NotFoundException('Station not found');
|
||||||
|
|
||||||
|
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
|
||||||
|
this.prisma.routeStop.count({ where: { stationId: id } }),
|
||||||
|
this.prisma.trainSchedule.count({ where: { originStationId: id } }),
|
||||||
|
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
|
||||||
|
(station as any)._count.stopTimes as number,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const constraints = [];
|
||||||
|
if (routeStopCount > 0)
|
||||||
|
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
|
||||||
|
const scheduleCount = originCount + destCount;
|
||||||
|
if (scheduleCount > 0)
|
||||||
|
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
|
||||||
|
if (stopTimeCount > 0)
|
||||||
|
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
|
||||||
|
|
||||||
|
if (constraints.length > 0)
|
||||||
|
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
|
||||||
|
|
||||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||||
|
|
||||||
await this.auditService.log({
|
await this.auditService.log({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { WalletService } from './wallet.service';
|
import { WalletService } from './wallet.service';
|
||||||
@@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard';
|
|||||||
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||||
export class WalletController {
|
export class WalletController {
|
||||||
constructor(private service: WalletService) {}
|
constructor(private service: WalletService) {}
|
||||||
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
|
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
|
||||||
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
|
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
|
||||||
|
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
|
||||||
|
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
export class WalletService {
|
export class WalletService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) {
|
||||||
|
const { search, page = '1', pageSize = '20' } = params;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(pageSize);
|
||||||
|
const where: any = {};
|
||||||
|
if (search) {
|
||||||
|
where.passenger = {
|
||||||
|
OR: [
|
||||||
|
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
|
||||||
|
{ user: { email: { contains: search, mode: 'insensitive' } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.walletAccount.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: parseInt(pageSize),
|
||||||
|
orderBy: { balanceMinor: 'desc' },
|
||||||
|
include: { passenger: { include: { user: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.walletAccount.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
items: items.map(w => ({
|
||||||
|
...w,
|
||||||
|
passenger: w.passenger ? {
|
||||||
|
id: w.passenger.id,
|
||||||
|
fullName: (w.passenger as any).user?.fullName ?? null,
|
||||||
|
email: (w.passenger as any).user?.email ?? null,
|
||||||
|
phone: (w.passenger as any).user?.phone ?? null,
|
||||||
|
} : null,
|
||||||
|
})),
|
||||||
|
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getWallet(passengerId: string) {
|
async getWallet(passengerId: string) {
|
||||||
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
|
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
|
||||||
if (!wallet) throw new NotFoundException('Wallet not found');
|
if (!wallet) throw new NotFoundException('Wallet not found');
|
||||||
@@ -18,4 +54,14 @@ export class WalletService {
|
|||||||
await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } });
|
await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } });
|
||||||
return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } });
|
return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAccount(id: string) {
|
||||||
|
const wallet = await this.prisma.walletAccount.findUnique({ where: { id } });
|
||||||
|
if (!wallet) throw new NotFoundException('Wallet account not found');
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }),
|
||||||
|
this.prisma.walletAccount.delete({ where: { id } }),
|
||||||
|
]);
|
||||||
|
return { deleted: true, accountId: id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal file
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../common/prisma.service';
|
||||||
|
|
||||||
|
const SEED_FLAG = 'SEED_SEGMENT_FARES';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds SegmentFareRule rows for every origin→destination pair on the
|
||||||
|
* Addis Ababa–Djibouti route across all active seat classes.
|
||||||
|
*
|
||||||
|
* Skip-if-loaded: uses Prisma upsert on the unique constraint
|
||||||
|
* (routeId, originStopSequence, destinationStopSequence, seatClassId, nationality).
|
||||||
|
* Re-running is safe — existing rows are updated in-place.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SegmentFareSeeder {
|
||||||
|
private readonly logger = new Logger(SegmentFareSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||||
|
this.logger.log(`Skipping segment fare seed — set ${SEED_FLAG}=true to enable`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await this.prisma.route.findFirst({
|
||||||
|
where: { active: true },
|
||||||
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
this.logger.warn('No active route found — skipping segment fare seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!seatClasses.length) {
|
||||||
|
this.logger.warn('No active seat classes found — skipping segment fare seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops = route.stops;
|
||||||
|
const validFrom = new Date('2025-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Rate table: ETB minor units per km, keyed by (nationalityType, bedPosition)
|
||||||
|
// null bedPosition = regular seat
|
||||||
|
const rateTable: Record<string, Record<string | 'null', number>> = {
|
||||||
|
LOCAL: { null: 3000, UPPER: 4000, MIDDLE: 5500, LOWER: 6000 },
|
||||||
|
INTERNATIONAL: { null: 6000, UPPER: 8000, MIDDLE: 11000, LOWER: 12000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let upserted = 0;
|
||||||
|
|
||||||
|
for (const seatClass of seatClasses) {
|
||||||
|
const natType = seatClass.nationalityType ?? 'LOCAL';
|
||||||
|
const bedPos = seatClass.bedPosition ?? 'null';
|
||||||
|
const ratePerKm = rateTable[natType]?.[bedPos] ?? rateTable['LOCAL']['null'];
|
||||||
|
|
||||||
|
for (let i = 0; i < stops.length - 1; i++) {
|
||||||
|
for (let j = i + 1; j < stops.length; j++) {
|
||||||
|
const origin = stops[i];
|
||||||
|
const dest = stops[j];
|
||||||
|
|
||||||
|
// Approximate distance: sum of per-stop distanceKm if available,
|
||||||
|
// otherwise fall back to sequence-gap × 50 km.
|
||||||
|
let distanceKm = 0;
|
||||||
|
for (let k = i; k < j; k++) {
|
||||||
|
distanceKm += stops[k + 1].distanceKm ?? 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFareMinor = Math.round(distanceKm * ratePerKm);
|
||||||
|
|
||||||
|
await this.prisma.segmentFareRule.upsert({
|
||||||
|
where: {
|
||||||
|
routeId_originStopSequence_destinationStopSequence_seatClassId_nationality: {
|
||||||
|
routeId: route.id,
|
||||||
|
originStopSequence: origin.sequence,
|
||||||
|
destinationStopSequence: dest.sequence,
|
||||||
|
seatClassId: seatClass.id,
|
||||||
|
nationality: natType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { baseFareMinor, validFrom },
|
||||||
|
create: {
|
||||||
|
routeId: route.id,
|
||||||
|
originStopSequence: origin.sequence,
|
||||||
|
destinationStopSequence: dest.sequence,
|
||||||
|
seatClassId: seatClass.id,
|
||||||
|
nationality: natType,
|
||||||
|
baseFareMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
validFrom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Segment fare seed complete — ${upserted} rules upserted ` +
|
||||||
|
`(${stops.length} stops × ${seatClasses.length} seat classes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardLayout from '../dashboard/layout';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
186
apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx
Normal file
186
apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import DataTable from '@/components/ui/DataTable';
|
||||||
|
import Badge from '@/components/ui/Badge';
|
||||||
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
|
import { appReleasesApi } from '@/lib/api';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
|
||||||
|
|
||||||
|
export default function AppReleasesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState({ ...EMPTY_FORM });
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['app-releases'],
|
||||||
|
queryFn: () => appReleasesApi.getAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); };
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (payload: any) =>
|
||||||
|
editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['app-releases'] });
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setFormError('');
|
||||||
|
flash(editing ? 'Release updated.' : 'Release created.');
|
||||||
|
},
|
||||||
|
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => appReleasesApi.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['app-releases'] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
flash('Release deleted.');
|
||||||
|
},
|
||||||
|
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); };
|
||||||
|
const openEdit = (r: any) => {
|
||||||
|
setEditing(r);
|
||||||
|
setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' });
|
||||||
|
setFormError('');
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.version.trim()) { setFormError('Version is required.'); return; }
|
||||||
|
saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const releases: any[] = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'os', label: 'OS',
|
||||||
|
render: (r: any) => (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${r.os === 'ios' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' : 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'}`}>
|
||||||
|
{r.os === 'ios' ? '🍎 iOS' : '🤖 Android'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'version', label: 'Version', render: (r: any) => <span className="font-mono font-semibold">{r.version}</span> },
|
||||||
|
{
|
||||||
|
key: 'forceUpdate', label: 'Force Update',
|
||||||
|
render: (r: any) => <Badge variant="status" status={r.forceUpdate ? 'ACTIVE' : 'INACTIVE'}>{r.forceUpdate ? 'Yes' : 'No'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storeLink', label: 'Store Link',
|
||||||
|
render: (r: any) => r.storeLink
|
||||||
|
? <a href={r.storeLink} target="_blank" rel="noreferrer" className="text-primary text-sm underline truncate max-w-[180px] block">{r.storeLink}</a>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
},
|
||||||
|
{ key: 'notes', label: 'Notes', render: (r: any) => <span className="text-sm text-muted-foreground truncate max-w-[200px] block">{r.notes || '—'}</span> },
|
||||||
|
{ key: 'createdAt', label: 'Created', render: (r: any) => <span className="text-sm">{formatDateTime(r.createdAt)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil },
|
||||||
|
{ label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">App Releases</h1>
|
||||||
|
<p className="text-muted-foreground">Manage mobile app version release control</p>
|
||||||
|
</div>
|
||||||
|
<ActionButton icon={Plus} onClick={openCreate}>New Release</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">✓ {successMessage}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<DataTable data={releases} columns={columns} actions={actions} loading={isLoading} emptyMessage="No releases found" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Modal */}
|
||||||
|
<Modal isOpen={formOpen} onClose={() => setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">OS</label>
|
||||||
|
<select className="input" value={form.os} onChange={(e) => setForm({ ...form, os: e.target.value })}>
|
||||||
|
<option value="android">Android</option>
|
||||||
|
<option value="ios">iOS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Version Number</label>
|
||||||
|
<input type="text" className="input" placeholder="e.g. 1.2.3" value={form.version}
|
||||||
|
onChange={(e) => setForm({ ...form, version: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Force Update</label>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{(['true', 'false'] as const).map((val) => (
|
||||||
|
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="forceUpdate" checked={form.forceUpdate === (val === 'true')}
|
||||||
|
onChange={() => setForm({ ...form, forceUpdate: val === 'true' })} className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">{val === 'true' ? 'Yes — force update' : 'No — optional'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Store Link</label>
|
||||||
|
<input type="url" className="input" placeholder="https://play.google.com/..." value={form.storeLink}
|
||||||
|
onChange={(e) => setForm({ ...form, storeLink: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Notes</label>
|
||||||
|
<textarea className="input min-h-[80px] resize-y" placeholder="Release notes or changelog..." value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setFormOpen(false)}>Cancel</ActionButton>
|
||||||
|
<ActionButton type="submit" loading={saveMutation.isPending}>
|
||||||
|
{editing ? 'Save Changes' : 'Create Release'}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteTarget}
|
||||||
|
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||||
|
onConfirm={async () => { if (deleteTarget) await deleteMutation.mutateAsync(deleteTarget.id); }}
|
||||||
|
title="Delete Release"
|
||||||
|
message={`Delete ${deleteTarget?.os} v${deleteTarget?.version}? This cannot be undone.`}
|
||||||
|
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
|
||||||
|
error={deleteError ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ export default function AuditLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const logs = data?.items || [];
|
const logs: any[] = Array.isArray(data?.items) ? data.items : [];
|
||||||
const stats = {
|
const stats = {
|
||||||
total: logs.length,
|
total: logs.length,
|
||||||
creates: logs.filter((l: any) => l.action === 'CREATE').length,
|
creates: logs.filter((l: any) => l.action === 'CREATE').length,
|
||||||
@@ -140,7 +140,7 @@ export default function AuditLogsPage() {
|
|||||||
icon={Download}
|
icon={Download}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const items = data?.items || [];
|
const items: any[] = Array.isArray(data?.items) ? data!.items : [];
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
|
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
|
||||||
const rows = items.map((l: any) => [
|
const rows = items.map((l: any) => [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
|
|||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { bookingsApi, apiClient } from '@/lib/api';
|
import { bookingsApi, apiClient } from '@/lib/api';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
|
||||||
import { BookingFilters } from '@/types';
|
import { BookingFilters } from '@/types';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
@@ -142,11 +142,47 @@ function BookingsPageContent() {
|
|||||||
key: 'bookingRef', label: 'Reference', sortable: true,
|
key: 'bookingRef', label: 'Reference', sortable: true,
|
||||||
render: (booking: any) => (
|
render: (booking: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono font-semibold">{booking.bookingRef}</div>
|
<div className="font-mono font-semibold flex items-center gap-1.5">
|
||||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
{booking.bookingRef}
|
||||||
|
{booking.isPackageBooking && (
|
||||||
|
<span className="inline-flex items-center gap-1 bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-semibold px-1.5 py-0.5 rounded" title="Package booking">
|
||||||
|
PKG
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{booking.isPackageBooking
|
||||||
|
? <div className="text-xs text-muted-foreground">Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div>
|
||||||
|
: <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'trip',
|
||||||
|
label: 'Trip',
|
||||||
|
render: (booking: any) => {
|
||||||
|
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
|
const returnDeparture = booking?.returnSchedule?.departureAt;
|
||||||
|
console.log(JSON.stringify(booking.packageId));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{booking.schedule?.originStation?.name || 'N/A'} → {booking.schedule?.destinationStation?.name || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{!isRoundTrip ? (
|
||||||
|
<span>{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'}</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} ·
|
||||||
|
{returnDeparture ? formatDateTimeShort(returnDeparture) : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'passengerNames', label: 'Names',
|
key: 'passengerNames', label: 'Names',
|
||||||
render: (booking: any) => {
|
render: (booking: any) => {
|
||||||
@@ -193,14 +229,6 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'passengerCount', label: 'Passengers',
|
|
||||||
render: (booking: any) => {
|
|
||||||
const adults = booking.adultCount || 0, children = booking.childCount || 0;
|
|
||||||
if (!adults && !children) return '—';
|
|
||||||
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'paymentStatus', label: 'Payment',
|
key: 'paymentStatus', label: 'Payment',
|
||||||
render: (booking: any) => (
|
render: (booking: any) => (
|
||||||
@@ -254,6 +282,14 @@ function BookingsPageContent() {
|
|||||||
<option value="CANCELLED">Cancelled</option>
|
<option value="CANCELLED">Cancelled</option>
|
||||||
<option value="BOARDED">Boarded</option>
|
<option value="BOARDED">Boarded</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select className="input w-44" value={extraFilters.bookingType}
|
||||||
|
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="ONE_WAY">One Way</option>
|
||||||
|
<option value="ROUND_TRIP">Round Trip</option>
|
||||||
|
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
|
||||||
|
<option value="PACKAGE">Package</option>
|
||||||
|
</select>
|
||||||
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
||||||
onClick={() => setShowExtraFilters(v => !v)}>
|
onClick={() => setShowExtraFilters(v => !v)}>
|
||||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||||
@@ -261,16 +297,6 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
{showExtraFilters && (
|
{showExtraFilters && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
|
||||||
<div>
|
|
||||||
<label className="label">Booking Type</label>
|
|
||||||
<select className="input" value={extraFilters.bookingType}
|
|
||||||
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
|
|
||||||
<option value="">All Types</option>
|
|
||||||
<option value="ONE_WAY">One Way</option>
|
|
||||||
<option value="ROUND_TRIP">Round Trip</option>
|
|
||||||
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Payment Status</label>
|
<label className="label">Payment Status</label>
|
||||||
<select className="input" value={extraFilters.paymentStatus}
|
<select className="input" value={extraFilters.paymentStatus}
|
||||||
@@ -324,9 +350,10 @@ function BookingsPageContent() {
|
|||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
|
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
|
||||||
|
b.isPackageBooking && b.packageName ? `PKG: ${b.packageName}` : null,
|
||||||
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
|
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
|
||||||
b.displayCurrency || b.currency || 'ETB',
|
b.displayCurrency || b.currency || 'ETB',
|
||||||
].map((tag) => (
|
].filter(Boolean).map((tag) => (
|
||||||
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
|
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -346,18 +373,31 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Package info */}
|
||||||
|
{b.isPackageBooking && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="Package" />
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Field label="Package Name" value={b.packageName || '—'} />
|
||||||
|
<Field label="Package Code" value={b.packageCode || '—'} mono />
|
||||||
|
<Field label="Tier" value={b.tierLabel || '—'} />
|
||||||
|
<Field label="Package ID" value={b.packageId || '—'} mono truncate />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Journey */}
|
{/* Journey */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Journey" />
|
<SectionHeader title="Journey" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<Field label="Origin" value={b.schedule?.originStation?.name} />
|
<Field label="Origin" value={b.schedule?.originStation?.name || b.schedule?.origin?.name} />
|
||||||
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
|
<Field label="Destination" value={b.schedule?.destinationStation?.name || b.schedule?.destination?.name} />
|
||||||
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
|
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
|
||||||
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
|
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
|
||||||
<Field label="Adults" value={String(b.adultCount ?? 0)} />
|
<Field label="Adults" value={String(b.adultCount ?? 0)} />
|
||||||
<Field label="Children" value={String(b.childCount ?? 0)} />
|
<Field label="Children" value={String(b.childCount ?? 0)} />
|
||||||
<Field label="Promo Code" value={b.promoCode || 'None'} />
|
<Field label="Promo Code" value={b.promoCode || 'None'} />
|
||||||
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
|
{!b.isPackageBooking && <Field label="Schedule ID" value={b.scheduleId} mono truncate />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -398,32 +438,39 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Seats */}
|
{/* Seats / Passengers */}
|
||||||
{b.seats && b.seats.length > 0 && (
|
{(() => {
|
||||||
<section>
|
const items: any[] = b.seats?.length ? b.seats : (b.passengers?.length ? b.passengers : []);
|
||||||
<SectionHeader title={`Seats (${b.seats.length})`} />
|
if (!items.length) return null;
|
||||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
const isSeats = !!b.seats?.length;
|
||||||
{b.seats.map((bs: any, i: number) => (
|
return (
|
||||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
<section>
|
||||||
<div className="flex items-center gap-3">
|
<SectionHeader title={`${isSeats ? 'Seats' : 'Passengers'} (${items.length})`} />
|
||||||
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||||
<div>
|
{items.map((p: any, i: number) => (
|
||||||
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
|
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-3">
|
||||||
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
|
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
||||||
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
|
<div>
|
||||||
</p>
|
<p className="text-sm font-semibold">{p.passengerName || p.fullName || p.name || '—'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{p.passengerCategory || p.category || '—'}{p.leg ? ` · Leg ${p.leg}` : ''}{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
|
||||||
|
{p.verifaydaVerified ? ' · ✓ Verified' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isSeats && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-mono font-semibold">{p.seat?.seatNumber || p.seatId || '—'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
))}
|
||||||
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
</section>
|
||||||
</div>
|
);
|
||||||
</div>
|
})()}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function ClassesPage() {
|
|||||||
const [filters, setFilters] = useState({ search: '' });
|
const [filters, setFilters] = useState({ search: '' });
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingClass, setEditingClass] = useState<any>(null);
|
const [editingClass, setEditingClass] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null });
|
||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -60,6 +60,10 @@ export default function ClassesPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||||||
},
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || 'Failed to delete class';
|
||||||
|
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -89,13 +93,16 @@ export default function ClassesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (cls: any) => {
|
const handleDelete = (cls: any) => {
|
||||||
setDeleteConfirm({ isOpen: true, class: cls });
|
setDeleteConfirm({ isOpen: true, class: cls, error: undefined });
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (deleteConfirm.class) {
|
if (!deleteConfirm.class) return;
|
||||||
|
try {
|
||||||
await deleteMutation.mutateAsync(deleteConfirm.class.id);
|
await deleteMutation.mutateAsync(deleteConfirm.class.id);
|
||||||
setDeleteConfirm({ isOpen: false, class: null });
|
setDeleteConfirm({ isOpen: false, class: null });
|
||||||
|
} catch {
|
||||||
|
// error is set by onError handler
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,6 +237,8 @@ export default function ClassesPage() {
|
|||||||
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
|
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDanger={true}
|
isDanger={true}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
error={deleteConfirm.error}
|
||||||
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
|
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -280,7 +289,7 @@ export default function ClassesPage() {
|
|||||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Base Fare (ETB) *</label>
|
<label className="label">Base Fare *</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="baseFareMinor"
|
name="baseFareMinor"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
|
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -147,6 +147,8 @@ export default function CoachesPage() {
|
|||||||
const [editingItem, setEditingItem] = useState<any>(null);
|
const [editingItem, setEditingItem] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||||
|
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
|
||||||
|
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -245,7 +247,6 @@ export default function CoachesPage() {
|
|||||||
coachTypeId: formData.get('coachTypeId') as string,
|
coachTypeId: formData.get('coachTypeId') as string,
|
||||||
arrangement: formData.get('arrangement') as string,
|
arrangement: formData.get('arrangement') as string,
|
||||||
capacity: parseInt(formData.get('capacity') as string),
|
capacity: parseInt(formData.get('capacity') as string),
|
||||||
sequence: parseInt(formData.get('sequence') as string),
|
|
||||||
status: formData.get('status') as string,
|
status: formData.get('status') as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,14 +362,6 @@ export default function CoachesPage() {
|
|||||||
|
|
||||||
// Coaches Columns
|
// Coaches Columns
|
||||||
const coachColumns = [
|
const coachColumns = [
|
||||||
{
|
|
||||||
key: 'sequence',
|
|
||||||
label: 'Sequence',
|
|
||||||
sortable: true,
|
|
||||||
render: (coach: any) => (
|
|
||||||
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'number',
|
key: 'number',
|
||||||
label: 'Number',
|
label: 'Number',
|
||||||
@@ -584,11 +577,53 @@ export default function CoachesPage() {
|
|||||||
{/* Utilization Tab */}
|
{/* Utilization Tab */}
|
||||||
{activeTab === 'utilization' && (() => {
|
{activeTab === 'utilization' && (() => {
|
||||||
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
|
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
|
||||||
|
|
||||||
|
const UTIL_COLS = [
|
||||||
|
{ key: 'number', label: 'Coach' },
|
||||||
|
{ key: 'coachType', label: 'Type' },
|
||||||
|
{ key: 'totalSeats', label: 'Total Seats' },
|
||||||
|
{ key: 'availableSeats', label: 'Available' },
|
||||||
|
{ key: 'bookedSeats', label: 'Booked' },
|
||||||
|
{ key: 'blockedSeats', label: 'Blocked' },
|
||||||
|
{ key: 'maintenanceSeats', label: 'Maintenance' },
|
||||||
|
{ key: 'utilizationRate', label: 'Utilization %' },
|
||||||
|
{ key: 'totalAssignments', label: 'Assignments' },
|
||||||
|
{ key: 'totalBookings', label: 'Total Bookings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
if (!rows.length) { alert('No data to export'); return; }
|
||||||
|
const headers = UTIL_COLS.map(c => c.label);
|
||||||
|
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
if (exportUtilFormat === 'pdf') {
|
||||||
|
const w = window.open('', '_blank')!;
|
||||||
|
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
|
||||||
|
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
|
||||||
|
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
|
||||||
|
w.document.write('</tbody></table></body></html>');
|
||||||
|
w.document.close(); w.print();
|
||||||
|
} else if (exportUtilFormat === 'excel') {
|
||||||
|
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
|
||||||
|
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
setExportUtilModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 space-y-4">
|
<div className="pt-6 space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
|
|
||||||
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
|
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
|
||||||
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
|
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
|
||||||
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
|
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
|
||||||
@@ -615,6 +650,26 @@ export default function CoachesPage() {
|
|||||||
loading={utilizationLoading}
|
loading={utilizationLoading}
|
||||||
emptyMessage="No coach utilization data available"
|
emptyMessage="No coach utilization data available"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Export Format</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||||
|
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
|
||||||
|
<ActionButton onClick={doExport}>Export</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -826,22 +881,6 @@ export default function CoachesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Sequence Number *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="sequence"
|
|
||||||
className="input"
|
|
||||||
defaultValue={editingItem?.sequence }
|
|
||||||
min="1"
|
|
||||||
required
|
|
||||||
placeholder="e.g., 1"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Position in train consist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Status *</label>
|
<label className="label">Status *</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -3,193 +3,48 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
|
||||||
|
import { navSections } from './sections/_shared';
|
||||||
|
import OverviewSection from './sections/OverviewSection';
|
||||||
|
import OperationsSection from './sections/OperationsSection';
|
||||||
|
import TourismSection from './sections/TourismSection';
|
||||||
|
import MasterDataSection from './sections/MasterDataSection';
|
||||||
|
import FinancialSection from './sections/FinancialSection';
|
||||||
|
import CustomerServicesSection from './sections/CustomerServicesSection';
|
||||||
|
import SecuritySection from './sections/SecuritySection';
|
||||||
|
import AnalyticsSection from './sections/AnalyticsSection';
|
||||||
|
import SystemSection from './sections/SystemSection';
|
||||||
|
|
||||||
const DocPage = () => {
|
export default function DocPage() {
|
||||||
const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||||
overview: true,
|
overview: true, operations: true, tourism: false, masterdata: false,
|
||||||
operations: true,
|
financial: false, services: false, security: false, analytics: false, system: false,
|
||||||
masterdata: false,
|
|
||||||
financial: false,
|
|
||||||
services: false,
|
|
||||||
security: false,
|
|
||||||
analytics: false,
|
|
||||||
system: false,
|
|
||||||
enhanced: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
const toggle = (id: string) => setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
setExpandedSections(prev => (({
|
|
||||||
...prev,
|
|
||||||
[section]: !prev[section]
|
|
||||||
})));
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
const scrollTo = (id: string) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const element = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (element) {
|
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 120, behavior: 'smooth' });
|
||||||
const headerOffset = 120;
|
|
||||||
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
|
||||||
const offsetPosition = elementPosition - headerOffset;
|
|
||||||
window.scrollTo({
|
|
||||||
top: offsetPosition,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
id: 'overview',
|
|
||||||
title: '📋 Overview & Getting Started',
|
|
||||||
items: [
|
|
||||||
{ id: 'about', label: 'Application Overview' },
|
|
||||||
{ id: 'features', label: 'Key Features' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'operations',
|
|
||||||
title: '📊 Operations',
|
|
||||||
items: [
|
|
||||||
{ id: 'bookings', label: 'Bookings' },
|
|
||||||
{ id: 'bookings-how', label: '→ How-To' },
|
|
||||||
{ id: 'passengers', label: 'Passengers' },
|
|
||||||
{ id: 'passengers-how', label: '→ How-To' },
|
|
||||||
{ id: 'tickets', label: 'Tickets' },
|
|
||||||
{ id: 'tickets-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'masterdata',
|
|
||||||
title: '🏢 Master Data',
|
|
||||||
items: [
|
|
||||||
{ id: 'stations', label: 'Stations' },
|
|
||||||
{ id: 'stations-how', label: '→ How-To' },
|
|
||||||
{ id: 'trains', label: 'Trains' },
|
|
||||||
{ id: 'trains-how', label: '→ How-To' },
|
|
||||||
{ id: 'coaches', label: 'Coaches' },
|
|
||||||
{ id: 'coaches-how', label: '→ How-To' },
|
|
||||||
{ id: 'seats', label: 'Seats' },
|
|
||||||
{ id: 'seats-how', label: '→ How-To' },
|
|
||||||
{ id: 'classes', label: 'Seat Classes' },
|
|
||||||
{ id: 'classes-how', label: '→ How-To' },
|
|
||||||
{ id: 'routes', label: 'Routes' },
|
|
||||||
{ id: 'routes-how', label: '→ How-To' },
|
|
||||||
{ id: 'schedules', label: 'Schedules' },
|
|
||||||
{ id: 'schedules-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'financial',
|
|
||||||
title: '💰 Financial',
|
|
||||||
items: [
|
|
||||||
{ id: 'pricing', label: 'Pricing & Fares' },
|
|
||||||
{ id: 'pricing-how', label: '→ How-To' },
|
|
||||||
{ id: 'currencies', label: 'Currencies' },
|
|
||||||
{ id: 'currencies-how', label: '→ How-To' },
|
|
||||||
{ id: 'payments', label: 'Payments' },
|
|
||||||
{ id: 'payments-how', label: '→ How-To' },
|
|
||||||
{ id: 'promos', label: 'Promo Codes' },
|
|
||||||
{ id: 'promos-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'services',
|
|
||||||
title: '🎁 Customer Services',
|
|
||||||
items: [
|
|
||||||
{ id: 'loyalty', label: 'Loyalty' },
|
|
||||||
{ id: 'loyalty-how', label: '→ How-To' },
|
|
||||||
{ id: 'support', label: 'Support' },
|
|
||||||
{ id: 'support-how', label: '→ How-To' },
|
|
||||||
{ id: 'notifications', label: 'Notifications' },
|
|
||||||
{ id: 'notifications-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'security',
|
|
||||||
title: '🔒 Security',
|
|
||||||
items: [
|
|
||||||
{ id: 'audit', label: 'Audit Logs' },
|
|
||||||
{ id: 'audit-how', label: '→ How-To' },
|
|
||||||
{ id: 'fraud', label: 'Fraud Detection' },
|
|
||||||
{ id: 'fraud-how', label: '→ How-To' },
|
|
||||||
{ id: 'verifayda', label: 'Verifayda' },
|
|
||||||
{ id: 'verifayda-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'analytics',
|
|
||||||
title: '📈 Analytics',
|
|
||||||
items: [
|
|
||||||
{ id: 'reports', label: 'Reports' },
|
|
||||||
{ id: 'reports-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'system',
|
|
||||||
title: '⚙️ System',
|
|
||||||
items: [
|
|
||||||
{ id: 'agents', label: 'Agents' },
|
|
||||||
{ id: 'agents-how', label: '→ How-To' },
|
|
||||||
{ id: 'users', label: 'Users' },
|
|
||||||
{ id: 'users-how', label: '→ How-To' },
|
|
||||||
{ id: 'system-config', label: 'System Config' },
|
|
||||||
{ id: 'system-config-how', label: '→ How-To' },
|
|
||||||
{ id: 'settings', label: 'Settings' },
|
|
||||||
{ id: 'settings-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'enhanced',
|
|
||||||
title: '✨ Enhanced Features',
|
|
||||||
items: [
|
|
||||||
{ id: 'excess-baggage', label: 'Excess Baggage' },
|
|
||||||
{ id: 'excess-baggage-how', label: '→ How-To' },
|
|
||||||
{ id: 'packages', label: 'Travel Packages' },
|
|
||||||
{ id: 'packages-how', label: '→ How-To' },
|
|
||||||
{ id: 'package-inquiries', label: 'Package Inquiries' },
|
|
||||||
{ id: 'package-inquiries-how', label: '→ How-To' },
|
|
||||||
{ id: 'health', label: 'Health Monitoring' },
|
|
||||||
{ id: 'health-how', label: '→ How-To' },
|
|
||||||
{ id: 'boarding', label: 'Boarding Management' },
|
|
||||||
{ id: 'boarding-how', label: '→ How-To' },
|
|
||||||
{ id: 'fare-config', label: 'Advanced Fare Config' },
|
|
||||||
{ id: 'fare-config-how', label: '→ How-To' },
|
|
||||||
{ id: 'payment-methods', label: 'Payment Methods' },
|
|
||||||
{ id: 'payment-methods-how', label: '→ How-To' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => (
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">{number}</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">{title}</h4>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
{/* Top bar */}
|
||||||
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
|
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-8 w-8 text-emerald-600" />
|
<FileText className="h-7 w-7 text-emerald-600" />
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
|
<h1 className="text-xl font-bold text-slate-900 dark:text-white">Documentation</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition" title="Opens API documentation in new tab">
|
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer"
|
||||||
<FileText className="h-4 w-4" />
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition">
|
||||||
View API Docs
|
<FileText className="h-4 w-4" /> API Docs
|
||||||
</a>
|
</a>
|
||||||
<Link href="/dashboard" target="_blank" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
|
<Link href="/dashboard" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm hover:bg-emerald-700 transition">
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" /> Dashboard
|
||||||
Dashboard
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,19 +52,25 @@ const DocPage = () => {
|
|||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
|
||||||
|
{/* Sidebar nav */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
|
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto">
|
||||||
<nav>
|
<nav>
|
||||||
{sections.map(section => (
|
{navSections.map(section => (
|
||||||
<div key={section.id}>
|
<div key={section.id}>
|
||||||
<button onClick={() => toggleSection(section.id)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700">
|
<button
|
||||||
|
onClick={() => toggle(section.id)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700"
|
||||||
|
>
|
||||||
<span>{section.title}</span>
|
<span>{section.title}</span>
|
||||||
{expandedSections[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{expanded[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
{expandedSections[section.id] && (
|
{expanded[section.id] && (
|
||||||
<div className="bg-slate-50 dark:bg-slate-700/50">
|
<div className="bg-slate-50 dark:bg-slate-700/50">
|
||||||
{section.items.map(item => (
|
{section.items.map(item => (
|
||||||
<button key={item.id} onClick={() => scrollToSection(item.id)} className="w-full text-left px-6 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
|
<button key={item.id} onClick={() => scrollTo(item.id)}
|
||||||
|
className="w-full text-left px-6 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -221,337 +82,29 @@ const DocPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
|
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-2">
|
||||||
|
<OverviewSection />
|
||||||
<div id="about">
|
<OperationsSection />
|
||||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
|
<TourismSection />
|
||||||
<p className="text-slate-600 dark:text-slate-300 mb-4">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
|
<MasterDataSection />
|
||||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-6 rounded-lg border border-emerald-200 dark:border-emerald-800">
|
<FinancialSection />
|
||||||
<h3 className="text-lg font-semibold text-emerald-900 dark:text-emerald-100 mb-2">🎆 Version 1.0.0 - Complete Platform Release</h3>
|
<CustomerServicesSection />
|
||||||
<ul className="text-emerald-800 dark:text-emerald-200 space-y-1">
|
<SecuritySection />
|
||||||
<li>• <strong>Excess Baggage:</strong> Complete baggage handling with agent tools and passenger self-pay</li>
|
<AnalyticsSection />
|
||||||
<li>• <strong>Travel Packages:</strong> Bundled offerings with tiered pricing and inquiry management</li>
|
<SystemSection />
|
||||||
<li>• <strong>Health Monitoring:</strong> Comprehensive system status and performance tracking</li>
|
|
||||||
<li>• <strong>Boarding Management:</strong> Gate operations and passenger processing workflows</li>
|
|
||||||
<li>• <strong>Advanced Fare Config:</strong> Dynamic pricing with segment-based rules</li>
|
|
||||||
<li>• <strong>Payment Methods:</strong> Multi-provider payment configuration and management</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="features" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🌟 Key Features</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Complete booking, passenger, fleet, and financial management.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BOOKINGS */}
|
|
||||||
<div id="bookings" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Bookings</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Manage passenger bookings with search, view, modify, and refund capabilities.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="bookings-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: Manage Bookings</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Bookings">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Bookings" in Operations section</li>
|
|
||||||
<li>View all bookings in table format</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Search & Filter">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Use search box for reference, email, or phone</li>
|
|
||||||
<li>Use Status dropdown to filter</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Manage Bookings">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "View Details" for full information</li>
|
|
||||||
<li>Click "Cancel Booking" to process refunds</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="system-config" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">⚙️ System Config</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Centralized system configuration management with feature flags and operational controls.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="system-config-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">⚙️ How-To: Manage System Configuration</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access System Config">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "System Config" in System section</li>
|
|
||||||
<li>View all configuration categories</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Rate Limiting">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Adjust auth endpoints limit (default: 5 req/min)</li>
|
|
||||||
<li>Set strict endpoints limit (default: 20 req/min)</li>
|
|
||||||
<li>Configure default endpoints limit (default: 100 req/min)</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Seat Booking Settings">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Set seat hold duration (default: 5 minutes)</li>
|
|
||||||
<li>Configure hold cutoff before departure (default: 2 hours)</li>
|
|
||||||
<li>Click "Save Changes" to apply</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* EXCESS BAGGAGE */}
|
|
||||||
<div id="excess-baggage" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📦 Excess Baggage</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Manage excess baggage charges at boarding with agent tools and passenger self-pay options.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="excess-baggage-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📦 How-To: Handle Excess Baggage</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Excess Baggage">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Excess Baggage" in Enhanced Features</li>
|
|
||||||
<li>View all baggage charges and their status</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Search & Filter">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Search by booking reference</li>
|
|
||||||
<li>Filter by status: PENDING, PAID, CASH_COLLECTED, EXPIRED, WAIVED</li>
|
|
||||||
<li>Use date filters for specific periods</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Manage Charges">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>"Resend Link" for pending charges to passenger</li>
|
|
||||||
<li>"Waive" charges with reason (supervisor authority)</li>
|
|
||||||
<li>"Delete" expired or waived charges</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TRAVEL PACKAGES */}
|
|
||||||
<div id="packages" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎒 Travel Packages</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Manage pilgrimage and group travel packages with tiered pricing and capacity management.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="packages-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎒 How-To: Manage Travel Packages</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Create Package">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "New Package" button</li>
|
|
||||||
<li>Fill package details: code, name, stations, schedules</li>
|
|
||||||
<li>Set capacity, validity period, and included services</li>
|
|
||||||
<li>Save package (starts in DRAFT status)</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Configure Price Tiers">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Tiers" on package to manage pricing</li>
|
|
||||||
<li>Add tiers: seat type, label, price, capacity</li>
|
|
||||||
<li>Edit existing tiers (limited if bookings exist)</li>
|
|
||||||
<li>Delete unused tiers</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Activate & Manage">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>"Activate" draft packages to make bookable</li>
|
|
||||||
<li>"Deactivate" active packages to stop new bookings</li>
|
|
||||||
<li>"Delete" packages with no bookings if needed</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PACKAGE INQUIRIES */}
|
|
||||||
<div id="package-inquiries" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📝 Package Inquiries</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Manage incoming package booking inquiries and track lead conversion.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="package-inquiries-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📝 How-To: Handle Package Inquiries</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="View Inquiries">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Package Inquiries" in Enhanced Features</li>
|
|
||||||
<li>Filter by package or inquiry status</li>
|
|
||||||
<li>View contact details, package interest, traveler count</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Update Status">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Use status dropdown: NEW → CONTACTED → CONVERTED/CLOSED</li>
|
|
||||||
<li>Mark as CONTACTED after first customer contact</li>
|
|
||||||
<li>Mark as CONVERTED when inquiry becomes booking</li>
|
|
||||||
<li>Mark as CLOSED if customer not interested</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Lead Management">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Respond to NEW inquiries within 24 hours</li>
|
|
||||||
<li>Follow up on CONTACTED inquiries regularly</li>
|
|
||||||
<li>Delete spam or duplicate inquiries as needed</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HEALTH MONITORING */}
|
|
||||||
<div id="health" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏥 Health Monitoring</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Monitor EDR Passenger API health with real-time system status and performance metrics.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="health-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏥 How-To: Monitor System Health</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Health Dashboard">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Health Monitoring" in Enhanced Features</li>
|
|
||||||
<li>View overall system status banner</li>
|
|
||||||
<li>Check individual probe cards (auto-refreshing)</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Interpret Health Checks">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Liveness: API process alive (30s refresh)</li>
|
|
||||||
<li>Readiness: Database connectivity + latency (30s refresh)</li>
|
|
||||||
<li>App Info: Version, uptime, environment (60s refresh)</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Troubleshoot Issues">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Red status: Check error details and system logs</li>
|
|
||||||
<li>High DB latency: Monitor database performance</li>
|
|
||||||
<li>Failed checks: Verify API server and connections</li>
|
|
||||||
<li>Use "Refresh" button for manual status update</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BOARDING MANAGEMENT */}
|
|
||||||
<div id="boarding" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚆 Boarding Management</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Manage gate operations and passenger boarding processes with real-time tracking.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="boarding-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚆 How-To: Manage Boarding Operations</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Boarding Management">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Boarding" in Enhanced Features</li>
|
|
||||||
<li>Select active trip/schedule for boarding</li>
|
|
||||||
<li>View real-time boarding dashboard</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Monitor Boarding Process">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Track total passengers expected vs boarded</li>
|
|
||||||
<li>Monitor boarding progress percentage</li>
|
|
||||||
<li>View gate status and any alerts</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Handle Boarding Operations">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Validate passenger tickets and documents</li>
|
|
||||||
<li>Resolve seat conflicts or issues</li>
|
|
||||||
<li>Process last-minute passengers and no-shows</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ADVANCED FARE CONFIG */}
|
|
||||||
<div id="fare-config" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Advanced Fare Config</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Configure complex fare rules and dynamic pricing strategies with segment-based pricing.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="fare-config-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Configure Advanced Fares</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Fare Configuration">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Advanced Fare Config" in Enhanced Features</li>
|
|
||||||
<li>Choose between Schedule Fares or Segment Fares</li>
|
|
||||||
<li>View existing fare rules and calculations</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Create Fare Rules">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Set fare amounts for specific schedules or segments</li>
|
|
||||||
<li>Define passenger categories (ADULT/CHILD) and nationalities</li>
|
|
||||||
<li>Configure validity periods and seasonal adjustments</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Manage Dynamic Pricing">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Apply route segment-specific pricing</li>
|
|
||||||
<li>Set nationality-based rate variations</li>
|
|
||||||
<li>Monitor fare engine integration and real-time calculations</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PAYMENT METHODS */}
|
|
||||||
<div id="payment-methods" className="border-t pt-8">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payment Methods</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-300">Configure and manage payment provider integrations with multi-provider support.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="payment-methods-how" className="border-t pt-8">
|
|
||||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Configure Payment Methods</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HowToStep number={1} title="Access Payment Configuration">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Click "Payment Methods" in Enhanced Features</li>
|
|
||||||
<li>View all configured payment providers</li>
|
|
||||||
<li>Check provider status and connectivity</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={2} title="Configure Providers">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Set up API credentials (URLs, keys, merchant IDs)</li>
|
|
||||||
<li>Configure transaction fees and limits</li>
|
|
||||||
<li>Enable/disable specific payment methods</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
<HowToStep number={3} title="Test & Validate">
|
|
||||||
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<li>Run test transactions for each provider</li>
|
|
||||||
<li>Validate webhook endpoints and security</li>
|
|
||||||
<li>Monitor API connectivity and error logs</li>
|
|
||||||
</ol>
|
|
||||||
</HowToStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 bg-slate-900 text-white py-8">
|
<div className="mt-12 bg-slate-900 text-white py-6">
|
||||||
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
|
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400 text-sm">
|
||||||
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0.0</p>
|
© 2026 Ethio-Djibouti Railway · Passenger Backoffice Documentation v1.0.0
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DocPage;
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user