mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||
|
||||
- name: Verify deployment health
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
|
||||
echo "Waiting for service to become healthy on port ${PORT}..."
|
||||
for i in $(seq 1 12); do
|
||||
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
|
||||
echo "Service is healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "Service failed health check after 120s — rolling back"
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
|
||||
exit 1
|
||||
|
||||
- name: Remove npm credentials from workspace
|
||||
if: always()
|
||||
run: rm -f .npmrc .npmrc_temp
|
||||
|
||||
@@ -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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FreightMeController],
|
||||
providers: [FreightMeService],
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [FreightMeController, CheckAvailabilityController],
|
||||
providers: [FreightMeService, CheckAvailabilityService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -61,6 +62,11 @@ import {
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
@@ -83,6 +89,8 @@ export class BookingsController {
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly customerTruckService: CustomerTruckService,
|
||||
private readonly containerReceiptService: ContainerReceiptService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -309,6 +317,94 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.listTrucks(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' })
|
||||
async addCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||
@ApiOperation({
|
||||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||
})
|
||||
async departCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: DepartCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Weighing + registering the load on exit is a warehouse/gate staff action.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can register a truck departure');
|
||||
}
|
||||
return this.customerTruckService.departTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Get(':id/received-pending-grn')
|
||||
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||||
async receivedPendingGrn(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// GRN is a warehouse-staff action — no customer access.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||
}
|
||||
return this.containerReceiptService.listReceivedPendingGrn(id);
|
||||
}
|
||||
|
||||
@Post(':id/generate-grn')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||||
})
|
||||
async generateGrn(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: GenerateGrnDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// GRN is a warehouse-staff action — no customer access.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||
}
|
||||
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
|
||||
@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CustomerTruckAssignmentsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
exports: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -145,7 +145,28 @@ export class BookingsService {
|
||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||
}
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
||||
const trucks: Array<{
|
||||
plateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
arrivedAt: string | null;
|
||||
containers: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.arrived_at AS "arrivedAt",
|
||||
string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
|
||||
FROM freight.customer_truck_assignments a
|
||||
LEFT JOIN freight.customer_truck_containers c
|
||||
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
|
||||
ORDER BY a.assigned_at`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
@@ -190,37 +211,85 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
||||
private buildCustomerTruckFreightOrderHtml(
|
||||
booking: Booking,
|
||||
trucks: Array<{
|
||||
plateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
arrivedAt: string | null;
|
||||
containers: string | null;
|
||||
}>,
|
||||
): string {
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
const bookingRows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
||||
['Driver Name', booking.customerTruckDriverName],
|
||||
['Truck Type', booking.customerTruckType],
|
||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const rowHtml = rows
|
||||
const bookingRowHtml = bookingRows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.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) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment</p>
|
||||
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${rowHtml}</table>
|
||||
<table>${bookingRowHtml}</table>
|
||||
${truckBlocks}
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</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; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.truck { page-break-inside: avoid; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
</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 })
|
||||
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);
|
||||
if (!businessInfo) {
|
||||
throw new BadRequestException(
|
||||
"No business license found for this TIN. Please check the number and try again.",
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
return this.etradeService.extractRegistrationData(businessInfo);
|
||||
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
managerName!: string;
|
||||
managerEmail?: string;
|
||||
managerPhone!: string;
|
||||
tinTaken?: boolean;
|
||||
|
||||
constructor(data: CompanyRegistrationData) {
|
||||
this.licenceNumber = data.licenceNumber;
|
||||
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
this.managerName = data.managerName;
|
||||
this.managerEmail = data.managerEmail;
|
||||
this.managerPhone = data.managerPhone;
|
||||
this.tinTaken = data.tinTaken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10)
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1567,9 +1567,7 @@ export class TrainSchedulingService {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
|
||||
|
||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||
await this.dispatchSchedule(schedule.id);
|
||||
@@ -1946,9 +1944,9 @@ export class TrainSchedulingService {
|
||||
where: { trainScheduleId: schedule.id },
|
||||
});
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation?.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
// Loading confirmation does NOT gate dispatch. Per-booking loading is
|
||||
// tracking only and the loaded-on-train step is optional — a scheduled train
|
||||
// dispatches without waiting on loading.
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<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(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
|
||||
|
||||
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
||||
|
||||
// Per-container receive: flag this container's unit as received into the
|
||||
// port so staff can raise the GRN over what's received.
|
||||
if (dto.bookingId && dto.containerId) {
|
||||
await manager.query(
|
||||
`UPDATE freight.booking_container_units bcu
|
||||
SET received_to_port = true,
|
||||
received_at = COALESCE(bcu.received_at, NOW()),
|
||||
updated_at = NOW()
|
||||
FROM freight.booking_containers bc, freight.containers cont
|
||||
WHERE bc.id = bcu.booking_container_id
|
||||
AND bc.booking_id = $1
|
||||
AND bc.deleted_at IS NULL
|
||||
AND cont.id = $2
|
||||
AND cont.container_number = bcu.container_number
|
||||
AND bcu.deleted_at IS NULL
|
||||
AND bcu.received_to_port = false`,
|
||||
[dto.bookingId, dto.containerId],
|
||||
);
|
||||
}
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
});
|
||||
if (!isTruckLeaving && item.bookingId) {
|
||||
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||||
// container as arrived (matched via the physical container number).
|
||||
if (item.containerId) {
|
||||
await manager.query(
|
||||
`UPDATE freight.customer_truck_assignments a
|
||||
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
|
||||
FROM freight.customer_truck_containers c
|
||||
JOIN freight.containers cont ON cont.container_number = c.container_number
|
||||
WHERE c.assignment_id = a.id
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.booking_id = $1
|
||||
AND cont.id = $2
|
||||
AND a.arrived_at IS NULL
|
||||
AND a.deleted_at IS NULL`,
|
||||
[item.bookingId, item.containerId],
|
||||
);
|
||||
// NB: import arrival changes nothing on the goods — received_to_port is
|
||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||
// load + weight are captured on truck departure, not arrival.
|
||||
}
|
||||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||
// handover is signed ONCE (before the first truck leaves), even though
|
||||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
|
||||
// Import self-haul: the exit paper names the pickup truck + all containers it
|
||||
// carries, so gate staff can verify the goods leaving on that truck.
|
||||
let truck: {
|
||||
plateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers: string;
|
||||
truckWeightTons: string | number | null;
|
||||
grossWeightKg: string | number | null;
|
||||
departedAt: string | null;
|
||||
} | null = null;
|
||||
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||
const [truckRow] = await this.dataSource.query(
|
||||
`SELECT a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
a.gross_weight_kg AS "grossWeightKg",
|
||||
a.departed_at AS "departedAt",
|
||||
string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
|
||||
COALESCE((
|
||||
SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.customer_truck_containers cc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||||
JOIN freight.booking_containers bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
AND bc.booking_id = c.booking_id
|
||||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||
), 0) AS "truckWeightTons"
|
||||
FROM freight.customer_truck_containers c
|
||||
JOIN freight.customer_truck_assignments a
|
||||
ON a.id = c.assignment_id AND a.deleted_at IS NULL
|
||||
JOIN freight.customer_truck_containers c2
|
||||
ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
|
||||
WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
|
||||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
|
||||
LIMIT 1`,
|
||||
[row.bookingId, row.containerNumber],
|
||||
);
|
||||
truck = truckRow ?? null;
|
||||
}
|
||||
|
||||
const bookingReference = row?.bookingReference || 'N/A';
|
||||
const reference =
|
||||
row?.releaseOrderReference ||
|
||||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
|
||||
inventoryStatus: row?.status ?? null,
|
||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||||
truckPlateNumber: truck?.plateNumber ?? null,
|
||||
truckDriverName: truck?.driverName ?? null,
|
||||
truckType: truck?.truckType ?? null,
|
||||
truckGateOut: truck?.departedAt ?? null,
|
||||
// Prefer the weighed gross captured on departure; fall back to the summed
|
||||
// container VGM when the truck hasn't been weighed yet.
|
||||
truckWeightKg: truck
|
||||
? Number(truck.grossWeightKg ?? 0) > 0
|
||||
? Number(truck.grossWeightKg)
|
||||
: Number(truck.truckWeightTons ?? 0) * 1000
|
||||
: null,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
|
||||
inventoryStatus: string | null;
|
||||
clearanceStatus: string;
|
||||
exitInspectionSummary?: string | null;
|
||||
truckPlateNumber?: string | null;
|
||||
truckDriverName?: string | null;
|
||||
truckType?: string | null;
|
||||
truckGateOut?: string | null;
|
||||
truckWeightKg?: number | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
|
||||
['Container Number', data.containerNumber],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
||||
[
|
||||
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
|
||||
`${(data.truckPlateNumber && data.truckWeightKg
|
||||
? data.truckWeightKg
|
||||
: data.weight
|
||||
).toLocaleString()} kg`,
|
||||
],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
['Clearance Status', data.clearanceStatus],
|
||||
...(data.truckPlateNumber
|
||||
? ([
|
||||
['Pickup Truck Plate', data.truckPlateNumber],
|
||||
['Truck Driver', data.truckDriverName],
|
||||
['Truck Type', data.truckType],
|
||||
[
|
||||
'Gate-Out Time',
|
||||
data.truckGateOut
|
||||
? new Date(data.truckGateOut).toLocaleString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: null,
|
||||
],
|
||||
] as [string, string | null][])
|
||||
: []),
|
||||
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||
];
|
||||
|
||||
|
||||
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,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
@@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "User Management",
|
||||
label: "Staff",
|
||||
href: "/um",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Booking requests",
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Contract requests",
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
@@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Document Clearance",
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
@@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Import Operations",
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
children: [
|
||||
@@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Export Operations",
|
||||
label: "Exports",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
children: [
|
||||
@@ -516,6 +517,38 @@ const filterSidebarByPermission = (
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
|
||||
const APP_TITLE = "EDR Freight Backoffice";
|
||||
|
||||
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
|
||||
const flattenSidebarItems = (
|
||||
sections: SidebarSection[],
|
||||
): { href: string; label: string }[] =>
|
||||
sections.flatMap((section) =>
|
||||
section.items.flatMap((item) => [
|
||||
...(item.href ? [{ href: item.href, label: item.label }] : []),
|
||||
...(item.children ?? [])
|
||||
.filter((child): child is SidebarItem & { href: string } =>
|
||||
Boolean(child.href),
|
||||
)
|
||||
.map((child) => ({ href: child.href, label: child.label })),
|
||||
]),
|
||||
);
|
||||
|
||||
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
|
||||
const findActiveSidebarLabel = (
|
||||
pathname: string,
|
||||
sections: SidebarSection[],
|
||||
): string | undefined => {
|
||||
const path = pathname.toLowerCase();
|
||||
const candidates = flattenSidebarItems(sections)
|
||||
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
|
||||
.sort((a, b) => b.href.length - a.href.length);
|
||||
|
||||
return candidates.find(
|
||||
({ href }) => path === href || path.startsWith(`${href}/`),
|
||||
)?.label;
|
||||
};
|
||||
|
||||
const DashboardShell = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -541,6 +574,14 @@ const DashboardShell = () => {
|
||||
: null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const activeLabel = findActiveSidebarLabel(
|
||||
location.pathname,
|
||||
sidebarSections,
|
||||
);
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
return <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";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
@@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg";
|
||||
const navClassNames = (active: boolean) =>
|
||||
active
|
||||
? {
|
||||
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
: {
|
||||
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
|
||||
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
||||
`${parentKey}/${item.href ?? item.label}/${index}`;
|
||||
@@ -65,7 +66,9 @@ const FreightSidebar = ({
|
||||
const isHrefActive = useCallback(
|
||||
(href: string) => {
|
||||
const normalized = href.toLowerCase();
|
||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||
return (
|
||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||
);
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
@@ -109,9 +112,7 @@ const FreightSidebar = ({
|
||||
|
||||
if (hasChildren) {
|
||||
const isLink = !!item.href;
|
||||
const active =
|
||||
(isLink ? isHrefActive(item.href!) : false) ||
|
||||
branchActive(item.children!);
|
||||
const active = isLink ? isHrefActive(item.href!) : false;
|
||||
const isOpen = openMap[key] ?? false;
|
||||
|
||||
return (
|
||||
@@ -124,7 +125,7 @@ const FreightSidebar = ({
|
||||
active={active}
|
||||
opened={isOpen}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={ () => toggle(key)}
|
||||
onClick={() => toggle(key)}
|
||||
rightSection={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -140,7 +141,9 @@ const FreightSidebar = ({
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="text-edr-muted transition-transform duration-200"
|
||||
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
@@ -161,8 +164,9 @@ const FreightSidebar = ({
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active}
|
||||
component={Link}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={() => onNavigate?.(item.href!)}
|
||||
to={item.href!}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -178,7 +182,7 @@ const FreightSidebar = ({
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={ "text-edr-muted!" }
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
@@ -232,14 +236,24 @@ const FreightSidebar = ({
|
||||
</Box>
|
||||
</Group>
|
||||
{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} />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 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>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,162 +1,40 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpRight,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
const normaliseIdentifier = (raw: string): string => {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
const local = digits.startsWith("251")
|
||||
? digits.slice(3)
|
||||
: digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
};
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<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 navigate = useNavigate();
|
||||
const { login, verifyMfa } = useAuth();
|
||||
@@ -165,7 +43,6 @@ const LoginPage = () => {
|
||||
const [otp, setOtp] = useState("");
|
||||
const [needsMfa, setNeedsMfa] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -179,15 +56,14 @@ const LoginPage = () => {
|
||||
setNormalizedIdentifier(normalized);
|
||||
|
||||
const result = await login({ email: normalized, password });
|
||||
console.log(result);
|
||||
if (result.mfaRequired) {
|
||||
setNeedsMfa(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to sign in with those credentials.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -201,194 +77,132 @@ const LoginPage = () => {
|
||||
try {
|
||||
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
||||
navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to verify the one-time code.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loginForm = (
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<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">
|
||||
Get Started
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Welcome back!
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Log in to access the freight backoffice & explore all logistics
|
||||
resources.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
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>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{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>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={submitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mfaForm = (
|
||||
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
<Box component="form" onSubmit={handleVerifyMfa}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<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">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Multi-factor verification
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
We sent a verification code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
<Text component="span" fw={500} c="var(--mantine-color-text)">
|
||||
{normalizedIdentifier}
|
||||
</span>
|
||||
</Text>
|
||||
. Enter it below to complete sign in.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Verification code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Stack gap="md">
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={(event) => setOtp(event.target.value)}
|
||||
placeholder="Enter the code"
|
||||
className={fieldClass}
|
||||
placeholder="0"
|
||||
disabled={submitting}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtp}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<button
|
||||
<Group grow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setNeedsMfa(false);
|
||||
setOtp("");
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={`${primaryButtonClass} min-w-0 flex-1`}
|
||||
color="edr-green"
|
||||
loading={submitting}
|
||||
disabled={otp.trim().length !== 6}
|
||||
>
|
||||
{submitting ? "Verifying..." : "Verify"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
return <AuthShell>{!needsMfa ? loginForm : mfaForm}</AuthShell>;
|
||||
};
|
||||
|
||||
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 { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_IMAGE = "/assets/edr_image.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
/** Muted deep-green brand wash for the left panel. */
|
||||
const LEFT_PANEL_BG =
|
||||
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||
|
||||
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||
const IMAGE_FADE_MASK =
|
||||
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<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"
|
||||
@@ -63,7 +48,7 @@ const RightPanelDecor = () => (
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
/** Headline shown in the top-left of the green panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
@@ -72,45 +57,63 @@ const LeftPanel = ({
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: Pick<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]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
className="absolute inset-0 h-auto w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/15 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
|
||||
<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"
|
||||
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">
|
||||
<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">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
<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 ??
|
||||
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"Sign in to book shipments, track cargo, and manage your freight on the Ethio–Djibouti Railway platform."}
|
||||
</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,
|
||||
opacity: 0.9,
|
||||
pointerEvents: "none",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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">
|
||||
<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="#"
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Help & Support
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -169,8 +172,8 @@ export default function AuthShell({
|
||||
</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 px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
register,
|
||||
@@ -30,53 +34,100 @@ export default function ETradeInfo({
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const hasData = mutation.data;
|
||||
const tinTaken = mutation.data?.tinTaken;
|
||||
const hasData =
|
||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
|
||||
if (!isValidTin(tin)) return;
|
||||
const result = await mutation.mutateAsync(tin);
|
||||
if (result) {
|
||||
if (result && !result.tinTaken) {
|
||||
onDataLoaded(result);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage =
|
||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
||||
const lastFetchedTin = useRef<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.error as any).message ||
|
||||
"Failed to fetch company information. Please try again."
|
||||
? extractApiError(mutation.error)
|
||||
: null;
|
||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
||||
// manually doesn't feel like something went wrong.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
<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"
|
||||
maxLength={10}
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
{errorMessage && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!isValidTin(tin) || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
)}
|
||||
</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 && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ interface RoleLicenseStepProps {
|
||||
/** Newly-selected files per profile id (not yet uploaded). */
|
||||
value: Record<string, File[]>;
|
||||
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,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
}: RoleLicenseStepProps) {
|
||||
const setFiles = (profileId: string, files: File[]) => {
|
||||
onChange({ ...value, [profileId]: files });
|
||||
@@ -123,6 +126,11 @@ export default function RoleLicenseStep({
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
errors={
|
||||
errors?.[profile.id]
|
||||
? { [LICENSE_FILE_KEY]: errors[profile.id] }
|
||||
: undefined
|
||||
}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
|
||||
@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
|
||||
SET_PASSWORD: "/api/auth/set-password",
|
||||
ME: "/api/auth/me",
|
||||
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
|
||||
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
||||
},
|
||||
|
||||
OTP: {
|
||||
@@ -106,6 +107,9 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
||||
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
|
||||
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
@@ -279,22 +280,39 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Fill the General Manager from the eTrade business owner. */
|
||||
const useOwnerAsManager = () => {
|
||||
if (!etradeOwner) return;
|
||||
setValue("generalManagerName", etradeOwner.name);
|
||||
setValue("generalManagerEmail", user.email);
|
||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
// General Manager source: the eTrade-registered business owner when a TIN
|
||||
// lookup found one, otherwise the registering user's own account details.
|
||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourcePhone = etradeOwner
|
||||
? etradeOwner.phone
|
||||
: toEthiopianE164(user.phoneNumber);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]);
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
@@ -341,6 +359,72 @@ export default function CompanyProfileForm({
|
||||
|
||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||
|
||||
// Hard verification for the documents step: required company-level
|
||||
// documents and a business license per operational profile must both be
|
||||
// present before the user can continue.
|
||||
const [documentFieldErrors, setDocumentFieldErrors] = useState<
|
||||
Record<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
|
||||
// are not user-editable — shown as a read-only confirmation once a TIN lookup
|
||||
// (or rehydration) has filled them in. The address fields below are separate:
|
||||
@@ -385,18 +469,21 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const licenseComplete = (roleProfiles ?? []).every(
|
||||
(p) =>
|
||||
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
// The documents step hard-blocks on required company documents and a
|
||||
// business license per operational profile before it auto-uploads and
|
||||
// submits — no partial-completion path forward.
|
||||
if (step === "documents") {
|
||||
const docErrors = validateRequiredDocuments();
|
||||
const licenseErrors = validateLicenses();
|
||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
||||
setDocumentFieldErrors(docErrors);
|
||||
setLicenseFieldErrors(licenseErrors);
|
||||
setSaveError("Please upload all required documents before continuing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -410,12 +497,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
@@ -450,8 +531,6 @@ export default function CompanyProfileForm({
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
@@ -507,7 +586,7 @@ export default function CompanyProfileForm({
|
||||
from eTrade · read-only
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReadOnlyField
|
||||
label="License Number"
|
||||
value={watch("licenceNumber")}
|
||||
@@ -586,22 +665,19 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{etradeOwner && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useOwnerAsManager}
|
||||
>
|
||||
Use owner as manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as business owner"
|
||||
description={
|
||||
etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
|
||||
: "Reuse your account's name, email and phone. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
@@ -737,15 +813,17 @@ export default function CompanyProfileForm({
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
errors={documentFieldErrors}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
onChange={handleDocumentFilesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
onChange={handleLicenseFilesChange}
|
||||
errors={licenseFieldErrors}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -24,7 +26,6 @@ export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,8 +42,8 @@ export default function LoginPage() {
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,60 +65,45 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={loading}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<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}
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</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 ? (
|
||||
<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}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
@@ -129,7 +115,7 @@ export default function LoginPage() {
|
||||
Create an account
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
@@ -34,14 +34,15 @@ import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
@@ -52,8 +53,14 @@ const userSchema = z
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
firstName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
lastName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
@@ -132,12 +139,30 @@ export default function SignupPage() {
|
||||
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
||||
// move to the OTP challenge.
|
||||
// Step 1 — form is valid: make sure the email/phone aren't already
|
||||
// registered, then send a fresh code to the chosen channel and move to
|
||||
// the OTP challenge.
|
||||
const requestOtp = async (data: FormData) => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
const availability = await api.auth.checkAvailability.call({
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
});
|
||||
if (availability.emailTaken && availability.phoneTaken) {
|
||||
setError("An account with this email and phone number already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.emailTaken) {
|
||||
setError("An account with this email already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.phoneTaken) {
|
||||
setError("An account with this phone number already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.auth.sendOTP.call(
|
||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||
);
|
||||
@@ -217,242 +242,270 @@ export default function SignupPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
tagline= "Smart Freight Operations"
|
||||
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="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
<div 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" >
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
||||
Create account
|
||||
</h1>
|
||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
< Stack gap = "sm" >
|
||||
<SimpleGrid cols={ { base: 1, sm: 2 } } spacing = "md" >
|
||||
<TextInput
|
||||
label="First name"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
placeholder = "John"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.firstName?.en?.message }
|
||||
{...register("firstName.en") }
|
||||
/>
|
||||
<TextInput
|
||||
label="Last name"
|
||||
placeholder="Doe"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
< TextInput
|
||||
label = "Last name"
|
||||
placeholder = "Doe"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.lastName?.en?.message }
|
||||
{...register("lastName.en") }
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
< TextInput
|
||||
label = "Email"
|
||||
type = "email"
|
||||
placeholder = "john@example.com"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.email?.message }
|
||||
{...register("email") }
|
||||
/>
|
||||
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phone"
|
||||
label="Phone"
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
< ControlledPhoneField
|
||||
control = { control }
|
||||
name = "phone"
|
||||
label = "Phone"
|
||||
required
|
||||
disabled = { sending }
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
<div className="space-y-1.5" >
|
||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||
Send verification code via
|
||||
</Text>
|
||||
< SegmentedControl
|
||||
fullWidth
|
||||
disabled = { sending }
|
||||
value = { channel }
|
||||
onChange = {(v) => setChannel(v as OtpChannel)
|
||||
}
|
||||
data = {
|
||||
[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className= "flex items-center justify-center gap-1.5" >
|
||||
<Smartphone size={ 14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className= "flex items-center justify-center gap-1.5" >
|
||||
<Mail size={ 14 } /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
< div >
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
placeholder = "Create a strong password"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.password?.message }
|
||||
{...register("password") }
|
||||
/>
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div 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"
|
||||
}`}
|
||||
{
|
||||
passwordValue.length > 0 ? (
|
||||
<div className= "mt-2 space-y-1" >
|
||||
{
|
||||
passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
met?(
|
||||
<Check className = "h-2.5 w-2.5" />
|
||||
): (
|
||||
<X className = "h-2.5 w-2.5" />
|
||||
)
|
||||
}
|
||||
</span>
|
||||
< span
|
||||
className = {`text-xs ${met ? "text-primary" : "text-gray-500"}`
|
||||
}
|
||||
>
|
||||
{ req.label }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
< PasswordInput
|
||||
label = "Confirm password"
|
||||
placeholder = "Re-enter your password"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.confirmPassword?.message }
|
||||
{...register("confirmPassword") }
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
{
|
||||
error ? (
|
||||
<Alert
|
||||
color= "red"
|
||||
variant = "light"
|
||||
icon = {< AlertCircle size = { 18} />}
|
||||
>
|
||||
{ error }
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
color = "edr-green"
|
||||
fullWidth
|
||||
loading = { sending }
|
||||
rightSection = {!sending ? <ArrowRight size={ 16 } /> : undefined}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
< p className = "text-center text-sm text-gray-500" >
|
||||
Already have an account ? { " "}
|
||||
< button
|
||||
type = "button"
|
||||
onClick = {() => navigate("/login")}
|
||||
className = "font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<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">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a 6-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
. Enter it to finish creating your account.
|
||||
<Stack gap= "md" >
|
||||
<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" >
|
||||
<ShieldCheck size={ 22 } />
|
||||
</span>
|
||||
</div>
|
||||
< div className = "space-y-1.5 text-center" >
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
||||
Verify your { otpChannel === "email" ? "email" : "phone" }
|
||||
</h1>
|
||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||
We sent a 6 - digit code to{ " " }
|
||||
<span className="font-medium text-gray-700" >
|
||||
{ otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
.Enter it to finish creating your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{otpError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{otpError}
|
||||
</Alert>
|
||||
{
|
||||
otpError ? (
|
||||
<Alert
|
||||
color= "red"
|
||||
variant = "light"
|
||||
icon = {< AlertCircle size = { 18} />}
|
||||
>
|
||||
{ otpError }
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={ 6 } align = "center" >
|
||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||
Verification code
|
||||
</Text>
|
||||
< PinInput
|
||||
length = { 6}
|
||||
type = "number"
|
||||
oneTimeCode
|
||||
value = { otpCode }
|
||||
placeholder = "0"
|
||||
disabled = { verifying }
|
||||
styles = {{ input: { textAlign: "center" } }}
|
||||
onChange = { setOtpCode }
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Verify & create account
|
||||
</Button>
|
||||
< Button
|
||||
color = "edr-green"
|
||||
fullWidth
|
||||
loading = { verifying }
|
||||
disabled = { verifying || otpCode.trim().length !== 6}
|
||||
onClick = { confirmOtp }
|
||||
>
|
||||
Verify & amp; create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
< div className = "flex items-center justify-between" >
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={sending || verifying}
|
||||
onClick={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
color = "gray"
|
||||
leftSection = {< ArrowLeft size = { 14} />}
|
||||
disabled = { sending || verifying}
|
||||
onClick = {() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || sending || verifying}
|
||||
onClick={resendOtp}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
Back
|
||||
</Button>
|
||||
< Button
|
||||
variant = "subtle"
|
||||
color = "edr-green"
|
||||
leftSection = {< RotateCw size = { 14} />}
|
||||
disabled = { resendIn > 0 || sending || verifying}
|
||||
onClick = { resendOtp }
|
||||
>
|
||||
{ resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Download, Lock, Truck } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
export function CustomerTruckAssignmentCard({
|
||||
booking,
|
||||
onAssigned,
|
||||
@@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({
|
||||
booking: Freight.IBooking;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const assigned = Boolean(booking.customerTruckAssignedAt);
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
|
||||
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
|
||||
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
|
||||
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
|
||||
booking.customerTruckContainerNumber ?? "",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const trucksKey = ["customer-trucks", booking.id];
|
||||
|
||||
const { data: trucks = [], isLoading } = useQuery({
|
||||
queryKey: trucksKey,
|
||||
queryFn: () => customerTrucksService.list(booking.id),
|
||||
});
|
||||
|
||||
const [plateNumber, setPlateNumber] = useState("");
|
||||
const [driverName, setDriverName] = useState("");
|
||||
const [truckType, setTruckType] = useState("");
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Physical container numbers on this booking — the customer picks which one to
|
||||
// load onto the truck instead of typing it. Falls back to free entry when the
|
||||
// booking has no container numbers recorded.
|
||||
const containerOptions = booking.containerNumbers ?? [];
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||
);
|
||||
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||
(n) => !assignedNumbers.has(n),
|
||||
);
|
||||
|
||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
||||
// staff register + weigh what was loaded when the truck leaves.
|
||||
const isExport = booking.tradeDirection === "EXPORT";
|
||||
|
||||
const submit = async () => {
|
||||
const payload = {
|
||||
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
|
||||
};
|
||||
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
|
||||
setError("All truck assignment fields are required.");
|
||||
return;
|
||||
}
|
||||
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
|
||||
setError("Container number must match ISO format, e.g. ABCD1234567.");
|
||||
return;
|
||||
}
|
||||
const resetForm = () => {
|
||||
setPlateNumber("");
|
||||
setDriverName("");
|
||||
setTruckType("");
|
||||
setContainers([]);
|
||||
setError(null);
|
||||
await assignMutation.mutateAsync({ id: booking.id, payload });
|
||||
onAssigned();
|
||||
};
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
customerTrucksService.add(booking.id, {
|
||||
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
// Import: containers are registered + weighed on departure, not here.
|
||||
containerNumbers: isExport ? containers : [],
|
||||
}),
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
resetForm();
|
||||
onAssigned();
|
||||
toast.success("Truck added");
|
||||
},
|
||||
onError: (e) => setError(errorMessage(e, "Could not add truck")),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId),
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
onAssigned();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "Could not remove truck")),
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
const downloadFreightOrder = async () => {
|
||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||
};
|
||||
|
||||
const submitAdd = () => {
|
||||
if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) {
|
||||
setError("Plate number, driver name and truck type are required.");
|
||||
return;
|
||||
}
|
||||
if (isExport && (containers.length < 1 || containers.length > 2)) {
|
||||
setError("Select 1 or 2 container numbers for this truck.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Stack gap="md">
|
||||
@@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({
|
||||
<Truck size={18} color="#0a9f6a" />
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
{assigned && (
|
||||
<Group gap={6} c="#0a9f6a">
|
||||
<Lock size={14} />
|
||||
<Text size="sm" fw={700}>
|
||||
Truck Assigned
|
||||
</Text>
|
||||
</Group>
|
||||
{trucks.length > 0 && (
|
||||
<Text size="sm" fw={700} c="#0a9f6a">
|
||||
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
)}
|
||||
</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 && (
|
||||
<Alert color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{assignMutation.isError && (
|
||||
<Alert color="red" variant="light">
|
||||
{assignMutation.error instanceof Error
|
||||
? assignMutation.error.message
|
||||
: "Truck assignment failed."}
|
||||
</Alert>
|
||||
|
||||
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
||||
{(isExport ? availableContainers.length > 0 : true) ? (
|
||||
<>
|
||||
<Divider label="Add a truck" labelPosition="center" />
|
||||
<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">
|
||||
<TextInput
|
||||
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 ? (
|
||||
{trucks.length > 0 && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
@@ -158,12 +275,8 @@ export function CustomerTruckAssignmentCard({
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
|
||||
Verify & Submit Assignment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,8 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -107,6 +109,11 @@ export const api = {
|
||||
"setPassword",
|
||||
authService.setPassword,
|
||||
),
|
||||
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||
"auth",
|
||||
"checkAvailability",
|
||||
authService.checkAvailability,
|
||||
),
|
||||
sendOTP: endpoint<OtpPayload, OtpResponse>(
|
||||
"auth",
|
||||
"sendOTP",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
} from "@/types/auth";
|
||||
import { client } from "@/utils/api";
|
||||
import { ApiResponse } from "@edr/types";
|
||||
@@ -23,7 +25,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
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,
|
||||
body,
|
||||
);
|
||||
@@ -31,9 +33,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
getMyInfo: async () => {
|
||||
const res = await client.get<AuthUser>(
|
||||
URL_CONSTANTS.USERS.ME,
|
||||
);
|
||||
const res = await client.get<AuthUser>(URL_CONSTANTS.USERS.ME);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -53,6 +53,14 @@ export const authService = {
|
||||
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) => {
|
||||
const res = await client.post<ApiResponse<OtpResponse>>(
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityPayload {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityResponse {
|
||||
emailTaken: boolean;
|
||||
phoneTaken: boolean;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
newPassword: 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")
|
||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||
stopTimes TripStopTime[]
|
||||
crowdSignals StationCrowdSignal[]
|
||||
crowdSignals StationCrowdSignal[]
|
||||
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
|
||||
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
|
||||
@@index([city, countryCode])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
@@ -506,6 +508,8 @@ model Booking {
|
||||
bookingRef String @unique
|
||||
passengerId String
|
||||
scheduleId String
|
||||
packageId String?
|
||||
priceTierId String?
|
||||
bookingType String @default("ONE_WAY")
|
||||
status BookingStatus @default(DRAFT)
|
||||
currency String @default("ETB")
|
||||
@@ -539,11 +543,15 @@ model Booking {
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
paymentReminderSentAt DateTime?
|
||||
packageDepartureStationId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], 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[]
|
||||
paymentIntent PaymentIntent?
|
||||
tickets Ticket[]
|
||||
@@ -1301,6 +1309,7 @@ model FraudAlert {
|
||||
context Json
|
||||
severity String @default("MEDIUM")
|
||||
acknowledged Boolean @default(false)
|
||||
acknowledgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
@@index([iamUserId, createdAt])
|
||||
@@index([acknowledged])
|
||||
@@ -1428,7 +1437,8 @@ model TravelPackage {
|
||||
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
|
||||
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||
priceTiers PackagePriceTier[]
|
||||
bookings PackageBooking[]
|
||||
bookings Booking[]
|
||||
packageBookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@index([status, validFrom])
|
||||
@@ -1446,7 +1456,8 @@ model PackagePriceTier {
|
||||
bookedSeats Int @default(0)
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
bookings PackageBooking[]
|
||||
bookings Booking[]
|
||||
packageBookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@unique([packageId, seatType])
|
||||
@@ -1472,10 +1483,12 @@ model PackageBooking {
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
packageDepartureStationId String?
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
|
||||
passengers PackageBookingPassenger[]
|
||||
paymentIntent PackagePaymentIntent?
|
||||
|
||||
@@ -1536,3 +1549,17 @@ model PackageInquiry {
|
||||
@@index([packageId])
|
||||
@@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() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['System Users', seedSystemUsers],
|
||||
['Stations', seedStations],
|
||||
['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||
['Route', seedRoute],
|
||||
['Coaches', seedCoaches],
|
||||
['Trips', seedTrips],
|
||||
['Fare Rules', seedFareRules],
|
||||
['Currency', seedCurrency],
|
||||
['Payment Methods', seedPaymentMethods],
|
||||
['Segment Fares', seedSegmentFares],
|
||||
['Notification Templates', seedNotificationTemplates],
|
||||
['Menu & Food', seedMenuAndFood],
|
||||
['Promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['Fraud Rules', seedFraudRules],
|
||||
['Kulubbi Package', seedKulubbiPackage],
|
||||
// ['System Users', seedSystemUsers],
|
||||
// ['Stations', seedStations],
|
||||
// ['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||
// ['Route', seedRoute],
|
||||
// ['Coaches', seedCoaches],
|
||||
// ['Trips', seedTrips],
|
||||
// ['Fare Rules', seedFareRules],
|
||||
// ['Currency', seedCurrency],
|
||||
// ['Payment Methods', seedPaymentMethods],
|
||||
// ['Segment Fares', seedSegmentFares],
|
||||
// ['Notification Templates', seedNotificationTemplates],
|
||||
// ['Menu & Food', seedMenuAndFood],
|
||||
// ['Promotions', seedPromotions],
|
||||
// ['FAQ', seedFAQ],
|
||||
// ['Fraud Rules', seedFraudRules],
|
||||
// ['Kulubbi Package', seedKulubbiPackage],
|
||||
// ['Package Bookings', seedPackageBookings],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -61,6 +61,9 @@ import { PackagesModule } from './modules/packages/packages.module';
|
||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||
import { HealthModule } from './modules/health/health.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({
|
||||
imports: [
|
||||
@@ -130,6 +133,8 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
||||
ExcessBaggageModule,
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
AppReleasesModule,
|
||||
ConfigurableFareModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
@@ -137,6 +142,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
||||
DynamicThrottlerGuard,
|
||||
EdrPassengerOrgSeeder,
|
||||
PassengerStaffUsersSeeder,
|
||||
SegmentFareSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -145,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||
private readonly segmentFareSeeder: SegmentFareSeeder,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -163,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
} catch (err) {
|
||||
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,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
@@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse();
|
||||
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 =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
: prismaMessage
|
||||
? HttpStatus.BAD_REQUEST
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const messageRaw =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: 'Internal server error';
|
||||
: prismaMessage ?? 'Internal server error';
|
||||
|
||||
const message =
|
||||
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,
|
||||
};
|
||||
|
||||
const items = await this.auditService.getLogs(filters);
|
||||
return { items };
|
||||
const result = await this.auditService.getLogs(filters);
|
||||
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
|
||||
}
|
||||
|
||||
@Get('logs/:id')
|
||||
|
||||
@@ -137,6 +137,12 @@ export class CreateBookingDto {
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => 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)' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
|
||||
@@ -202,9 +202,12 @@ export class BookingsService {
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
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 = {};
|
||||
|
||||
|
||||
if (search) {
|
||||
const iamRows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT u.id FROM iam.users u
|
||||
@@ -229,10 +232,10 @@ export class BookingsService {
|
||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||
if (bookingType) where.bookingType = bookingType;
|
||||
if (bookingType && !onlyPackages) where.bookingType = bookingType;
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
@@ -240,17 +243,126 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
PAID: 'SUCCEEDED',
|
||||
PENDING: 'REQUIRES_ACTION',
|
||||
FAILED: 'FAILED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
};
|
||||
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
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({
|
||||
where,
|
||||
skip,
|
||||
@@ -261,12 +373,27 @@ export class BookingsService {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
package: { select: { id: true, name: true, code: true } },
|
||||
priceTier: { select: { id: true, label: true } },
|
||||
},
|
||||
}),
|
||||
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
|
||||
? 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)`,
|
||||
@@ -275,59 +402,86 @@ export class BookingsService {
|
||||
: [];
|
||||
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 {
|
||||
items: items.map(booking => {
|
||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||
// 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),
|
||||
},
|
||||
items: allItems,
|
||||
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 === 'TRANSIT') return this.createTransitBooking(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 { 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;
|
||||
let displayTotalMinor = fareCalculation.totalMinor;
|
||||
@@ -371,20 +527,18 @@ export class BookingsService {
|
||||
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;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
|
||||
} else {
|
||||
// Child: first child is free, subsequent children pay full fare
|
||||
if (!freeChildUsed) {
|
||||
fareMinor = 0;
|
||||
freeChildUsed = true;
|
||||
} else {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
@@ -401,6 +555,7 @@ export class BookingsService {
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
seats: {
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
@@ -421,6 +576,12 @@ export class BookingsService {
|
||||
});
|
||||
|
||||
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 });
|
||||
return { ...booking, fareBreakdown: fareCalculation };
|
||||
}
|
||||
@@ -468,23 +629,38 @@ export class BookingsService {
|
||||
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
|
||||
const [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)
|
||||
]);
|
||||
|
||||
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
||||
// Package bookings use fixed tier price split equally across both legs
|
||||
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||
let combinedBaseFareMinor: number;
|
||||
let discountMinor = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
let loyaltyMinor = 0;
|
||||
let totalMinor: number;
|
||||
|
||||
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 totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
@@ -492,34 +668,27 @@ export class BookingsService {
|
||||
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 returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
|
||||
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
|
||||
} else {
|
||||
// Child fare for outbound
|
||||
if (!outboundFreeChildUsed) {
|
||||
outboundFareMinor = 0;
|
||||
outboundFreeChildUsed = true;
|
||||
} else {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for return
|
||||
if (!returnFreeChildUsed) {
|
||||
returnFareMinor = 0;
|
||||
returnFreeChildUsed = true;
|
||||
} else {
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
@@ -541,6 +710,7 @@ export class BookingsService {
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
seats: {
|
||||
create: [
|
||||
...passengersWithFares.map(p => ({
|
||||
@@ -586,6 +756,13 @@ export class BookingsService {
|
||||
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 });
|
||||
|
||||
return {
|
||||
@@ -1048,6 +1225,34 @@ export class BookingsService {
|
||||
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(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -1182,7 +1387,64 @@ export class BookingsService {
|
||||
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 {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
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' })
|
||||
@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 {
|
||||
|
||||
@@ -81,8 +81,10 @@ export class GuestBookingService {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||
?? (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');
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
@@ -142,21 +144,34 @@ export class GuestBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate fare
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(
|
||||
dto.scheduleId,
|
||||
dto.seatClassId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
primaryNationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
|
||||
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
||||
let baseFareMinor: number;
|
||||
let paidChildrenCount: number;
|
||||
let childUnitFare: number;
|
||||
|
||||
if (isPackageOneway) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||
baseFareMinor = tier.priceMinor;
|
||||
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 paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const childFareMinor = childUnitFare * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -215,6 +230,7 @@ export class GuestBookingService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
@@ -229,7 +245,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
@@ -256,7 +272,7 @@ export class GuestBookingService {
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
@@ -306,10 +322,16 @@ export class GuestBookingService {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||
const synth = (sched: any, stationId: string, seq: number) => {
|
||||
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
||||
return { stationId, sequence: seq, station };
|
||||
};
|
||||
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 (!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 });
|
||||
}
|
||||
|
||||
// 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 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([
|
||||
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),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
||||
if (isPackageRoundTrip) {
|
||||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||||
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
|
||||
const halfMinor = Math.round(tier.priceMinor / 2);
|
||||
outboundBaseFare = halfMinor;
|
||||
returnBaseFare = tier.priceMinor - halfMinor;
|
||||
paidChildrenCount = childCount;
|
||||
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;
|
||||
|
||||
let discountMinor = 0;
|
||||
@@ -415,6 +455,7 @@ export class GuestBookingService {
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
@@ -432,7 +473,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
@@ -447,7 +488,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -476,7 +517,7 @@ export class GuestBookingService {
|
||||
returnBaseFareMinor: returnBaseFare,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
|
||||
@@ -329,7 +329,7 @@ export class ConfigurableFareService {
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const freeChildren = Math.min(childCount, adultCount);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
|
||||
@@ -130,8 +130,8 @@ export class FareEngineService {
|
||||
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
const freeChildrenCount = Math.min(childCount, 1);
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const freeChildrenCount = Math.min(childCount, adultCount);
|
||||
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
|
||||
|
||||
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||
// First child is free, but pays premium and insurance
|
||||
@@ -169,7 +169,7 @@ export class FareEngineService {
|
||||
`Total fare/pax: ${farePerPassengerMinor} 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`,
|
||||
` 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 { FraudService, FraudRuleConfig } from './fraud.service';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
@@ -48,6 +48,31 @@ export class FraudController {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FraudService } from './fraud.service';
|
||||
import { FraudController } from './fraud.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [HttpModule, TypeOrmModule],
|
||||
providers: [FraudService],
|
||||
controllers: [FraudController],
|
||||
exports: [FraudService],
|
||||
|
||||
@@ -164,6 +164,16 @@ export class FraudService {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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 { LoyaltyService } from './loyalty.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class LoyaltyController {
|
||||
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/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); }
|
||||
@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 {
|
||||
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) {
|
||||
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');
|
||||
@@ -40,4 +76,15 @@ export class LoyaltyService {
|
||||
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
|
||||
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 { 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 { 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 { JwtGuard } from '../../common/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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -78,6 +91,30 @@ export class PackagesController {
|
||||
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')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get package details' })
|
||||
@@ -149,12 +186,4 @@ export class PackagesController {
|
||||
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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@@ -93,6 +93,12 @@ export class BookPackagePassengerDto {
|
||||
@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 {
|
||||
@ApiProperty() @IsUUID() packageId: string;
|
||||
@ApiProperty() @IsUUID() priceTierId: string;
|
||||
@@ -112,4 +118,10 @@ export class BookPackageDto {
|
||||
@ApiProperty({ type: [BookPackagePassengerDto] })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => 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 { PackagesService } from './packages.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, CurrencyModule],
|
||||
imports: [PrismaModule, CurrencyModule, BookingsModule],
|
||||
controllers: [PackagesController],
|
||||
providers: [PackagesService],
|
||||
exports: [PackagesService],
|
||||
|
||||
@@ -3,6 +3,34 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
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 {
|
||||
return 'PKG-' + Array.from({ length: 6 }, () =>
|
||||
@@ -15,8 +43,102 @@ export class PackagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
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) {
|
||||
return this.prisma.packageInquiry.create({
|
||||
data: {
|
||||
@@ -74,7 +196,7 @@ export class PackagesService {
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
},
|
||||
orderBy: { validFrom: 'asc' },
|
||||
});
|
||||
}).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' })));
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
@@ -82,12 +204,19 @@ export class PackagesService {
|
||||
where: { id },
|
||||
include: {
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
return pkg;
|
||||
return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' };
|
||||
}
|
||||
|
||||
create(dto: CreatePackageDto) {
|
||||
@@ -209,13 +338,30 @@ export class PackagesService {
|
||||
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
||||
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;
|
||||
if (passengerCount > remaining) {
|
||||
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 displayTotalMinor =
|
||||
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) {
|
||||
@@ -296,6 +456,29 @@ export class PackagesService {
|
||||
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) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
|
||||
@@ -433,39 +433,49 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
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 },
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
include: { 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
|
||||
const usage = await this.checkPassengerUsage(id);
|
||||
const usage = await this.checkPassengerUsage(passengerId);
|
||||
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);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
|
||||
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
|
||||
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.passenger.delete({ where: { id } }),
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }),
|
||||
this.prisma.walletAccount.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.notification.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.savedRoute.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.packageBooking.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }),
|
||||
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }),
|
||||
this.prisma.booking.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }),
|
||||
this.prisma.journey.deleteMany({ where: { passengerId } }),
|
||||
this.prisma.passenger.delete({ where: { id: passengerId } }),
|
||||
]);
|
||||
|
||||
return { deleted: true, passengerId: id };
|
||||
return { deleted: true, passengerId };
|
||||
}
|
||||
|
||||
async checkPassengerUsage(id: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
@@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
export class PaymentsController {
|
||||
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")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -55,6 +55,13 @@ export class PaymentsService {
|
||||
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: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
|
||||
@@ -50,8 +50,8 @@ export class CreateScheduleDto {
|
||||
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
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));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -66,4 +66,29 @@ Nationality-Based:
|
||||
getFareQuote(@Body() dto: FareQuoteDto) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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 { FareEngineService } from '../fare-engine/fare-engine.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(
|
||||
schedule: ScheduleWithIncludes,
|
||||
originStationId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
@Injectable()
|
||||
export class SeatClassesService {
|
||||
@@ -45,8 +46,24 @@ export class SeatClassesService {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter";
|
||||
export class SeatsController {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
@Get("seatmap/:scheduleId")
|
||||
@SetMetadata('isPublic', true)
|
||||
|
||||
@@ -61,11 +61,12 @@ export class SeatsService {
|
||||
const resolvedBedPosition = isBedCoach
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
status: effectiveStatus,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
@@ -245,11 +246,16 @@ export class SeatsService {
|
||||
holdFrom === undefined || holdTo === undefined ||
|
||||
(holdFrom < reqTo && reqFrom < holdTo);
|
||||
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Check direction conflict
|
||||
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');
|
||||
}
|
||||
@@ -367,7 +373,24 @@ export class SeatsService {
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
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 reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
@@ -604,6 +627,56 @@ export class SeatsService {
|
||||
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[]> {
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
@@ -794,11 +867,21 @@ export class SeatsService {
|
||||
if (expired.length === 0) return;
|
||||
|
||||
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({
|
||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
|
||||
// Only reset seats that have no remaining active holds
|
||||
const stillHeld = await this.prisma.seatHold.findMany({
|
||||
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() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { TripProgressService } from './trip-progress.service';
|
||||
import { SegmentSeatsController } from './segments.controller';
|
||||
import { SegmentFareController } from './segment-fare.controller';
|
||||
import { SegmentFareService } from './segment-fare.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SegmentSeatsController],
|
||||
controllers: [SegmentSeatsController, SegmentFareController],
|
||||
providers: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService,
|
||||
PrismaService
|
||||
SegmentFareService,
|
||||
PrismaService,
|
||||
],
|
||||
exports: [
|
||||
SegmentsService,
|
||||
EnhancedSeatsService,
|
||||
TripProgressService
|
||||
]
|
||||
TripProgressService,
|
||||
SegmentFareService,
|
||||
],
|
||||
})
|
||||
export class SegmentsModule {}
|
||||
@@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
interface StationFilters {
|
||||
search?: string;
|
||||
@@ -96,7 +97,35 @@ export class StationsService {
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
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 { Throttle } from '@nestjs/throttler';
|
||||
import { WalletService } from './wallet.service';
|
||||
@@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||
export class WalletController {
|
||||
constructor(private service: WalletService) {}
|
||||
@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); }
|
||||
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
|
||||
@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 {
|
||||
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) {
|
||||
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
|
||||
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 } });
|
||||
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 = {
|
||||
total: logs.length,
|
||||
creates: logs.filter((l: any) => l.action === 'CREATE').length,
|
||||
@@ -140,7 +140,7 @@ export default function AuditLogsPage() {
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const items = data?.items || [];
|
||||
const items: any[] = Array.isArray(data?.items) ? data!.items : [];
|
||||
if (!items.length) return;
|
||||
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
|
||||
const rows = items.map((l: any) => [
|
||||
|
||||
@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { bookingsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
|
||||
import { BookingFilters } from '@/types';
|
||||
|
||||
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,
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">{booking.bookingRef}</div>
|
||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||
<div className="font-mono font-semibold flex items-center gap-1.5">
|
||||
{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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
render: (booking: any) => {
|
||||
@@ -193,14 +229,6 @@ function BookingsPageContent() {
|
||||
</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',
|
||||
render: (booking: any) => (
|
||||
@@ -254,6 +282,14 @@ function BookingsPageContent() {
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="BOARDED">Boarded</option>
|
||||
</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"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
@@ -261,16 +297,6 @@ function BookingsPageContent() {
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<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>
|
||||
<label className="label">Payment Status</label>
|
||||
<select className="input" value={extraFilters.paymentStatus}
|
||||
@@ -324,9 +350,10 @@ function BookingsPageContent() {
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
(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.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 className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
||||
</span>
|
||||
@@ -346,18 +373,31 @@ function BookingsPageContent() {
|
||||
</div>
|
||||
</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 */}
|
||||
<section>
|
||||
<SectionHeader title="Journey" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Origin" value={b.schedule?.originStation?.name} />
|
||||
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
|
||||
<Field label="Origin" value={b.schedule?.originStation?.name || b.schedule?.origin?.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="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
|
||||
<Field label="Adults" value={String(b.adultCount ?? 0)} />
|
||||
<Field label="Children" value={String(b.childCount ?? 0)} />
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -398,32 +438,39 @@ function BookingsPageContent() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seats */}
|
||||
{b.seats && b.seats.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title={`Seats (${b.seats.length})`} />
|
||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||
{b.seats.map((bs: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
|
||||
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
|
||||
</p>
|
||||
{/* Seats / Passengers */}
|
||||
{(() => {
|
||||
const items: any[] = b.seats?.length ? b.seats : (b.passengers?.length ? b.passengers : []);
|
||||
if (!items.length) return null;
|
||||
const isSeats = !!b.seats?.length;
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title={`${isSeats ? 'Seats' : 'Passengers'} (${items.length})`} />
|
||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||
{items.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<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>
|
||||
{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 className="text-right">
|
||||
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamps */}
|
||||
<section>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ClassesPage() {
|
||||
const [filters, setFilters] = useState({ search: '' });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
@@ -60,6 +60,10 @@ export default function ClassesPage() {
|
||||
onSuccess: () => {
|
||||
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>) => {
|
||||
@@ -89,13 +93,16 @@ export default function ClassesPage() {
|
||||
};
|
||||
|
||||
const handleDelete = (cls: any) => {
|
||||
setDeleteConfirm({ isOpen: true, class: cls });
|
||||
setDeleteConfirm({ isOpen: true, class: cls, error: undefined });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.class) {
|
||||
if (!deleteConfirm.class) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.class.id);
|
||||
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}?`}
|
||||
confirmText="Delete"
|
||||
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."
|
||||
/>
|
||||
|
||||
@@ -280,7 +289,7 @@ export default function ClassesPage() {
|
||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare (ETB) *</label>
|
||||
<label className="label">Base Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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 ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -147,6 +147,8 @@ export default function CoachesPage() {
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
|
||||
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -245,7 +247,6 @@ export default function CoachesPage() {
|
||||
coachTypeId: formData.get('coachTypeId') as string,
|
||||
arrangement: formData.get('arrangement') as string,
|
||||
capacity: parseInt(formData.get('capacity') as string),
|
||||
sequence: parseInt(formData.get('sequence') as string),
|
||||
status: formData.get('status') as string,
|
||||
};
|
||||
|
||||
@@ -361,14 +362,6 @@ export default function CoachesPage() {
|
||||
|
||||
// Coaches Columns
|
||||
const coachColumns = [
|
||||
{
|
||||
key: 'sequence',
|
||||
label: 'Sequence',
|
||||
sortable: true,
|
||||
render: (coach: any) => (
|
||||
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
label: 'Number',
|
||||
@@ -584,11 +577,53 @@ export default function CoachesPage() {
|
||||
{/* Utilization Tab */}
|
||||
{activeTab === 'utilization' && (() => {
|
||||
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 (
|
||||
<div className="pt-6 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
<DataTable
|
||||
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: '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> },
|
||||
@@ -615,6 +650,26 @@ export default function CoachesPage() {
|
||||
loading={utilizationLoading}
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
@@ -826,22 +881,6 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</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>
|
||||
<label className="label">Status *</label>
|
||||
<select
|
||||
|
||||
@@ -3,193 +3,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 = () => {
|
||||
const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({
|
||||
overview: true,
|
||||
operations: true,
|
||||
masterdata: false,
|
||||
financial: false,
|
||||
services: false,
|
||||
security: false,
|
||||
analytics: false,
|
||||
system: false,
|
||||
enhanced: false,
|
||||
export default function DocPage() {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||
overview: true, operations: true, tourism: false, masterdata: false,
|
||||
financial: false, services: false, security: false, analytics: false, system: false,
|
||||
});
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => (({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
})));
|
||||
};
|
||||
const toggle = (id: string) => setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const scrollTo = (id: string) => {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
const headerOffset = 120;
|
||||
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
||||
const offsetPosition = elementPosition - headerOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
const el = document.getElementById(id);
|
||||
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 120, behavior: 'smooth' });
|
||||
}, 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 (
|
||||
<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="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-8 w-8 text-emerald-600" />
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
|
||||
<FileText className="h-7 w-7 text-emerald-600" />
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-white">Documentation</h1>
|
||||
</div>
|
||||
<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">
|
||||
<FileText className="h-4 w-4" />
|
||||
View API Docs
|
||||
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition">
|
||||
<FileText className="h-4 w-4" /> API Docs
|
||||
</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">
|
||||
<Home className="h-4 w-4" />
|
||||
Dashboard
|
||||
<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" /> Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,19 +52,25 @@ const DocPage = () => {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* Sidebar nav */}
|
||||
<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>
|
||||
{sections.map(section => (
|
||||
{navSections.map(section => (
|
||||
<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>
|
||||
{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>
|
||||
{expandedSections[section.id] && (
|
||||
{expanded[section.id] && (
|
||||
<div className="bg-slate-50 dark:bg-slate-700/50">
|
||||
{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}
|
||||
</button>
|
||||
))}
|
||||
@@ -221,337 +82,29 @@ const DocPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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 id="about">
|
||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
|
||||
<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>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-6 rounded-lg border border-emerald-200 dark:border-emerald-800">
|
||||
<h3 className="text-lg font-semibold text-emerald-900 dark:text-emerald-100 mb-2">🎆 Version 1.0.0 - Complete Platform Release</h3>
|
||||
<ul className="text-emerald-800 dark:text-emerald-200 space-y-1">
|
||||
<li>• <strong>Excess Baggage:</strong> Complete baggage handling with agent tools and passenger self-pay</li>
|
||||
<li>• <strong>Travel Packages:</strong> Bundled offerings with tiered pricing and inquiry management</li>
|
||||
<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 className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-2">
|
||||
<OverviewSection />
|
||||
<OperationsSection />
|
||||
<TourismSection />
|
||||
<MasterDataSection />
|
||||
<FinancialSection />
|
||||
<CustomerServicesSection />
|
||||
<SecuritySection />
|
||||
<AnalyticsSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 bg-slate-900 text-white py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
|
||||
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0.0</p>
|
||||
<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 text-sm">
|
||||
© 2026 Ethio-Djibouti Railway · Passenger Backoffice Documentation v1.0.0
|
||||
</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