fix data table

This commit is contained in:
natib21
2026-06-20 09:54:05 +00:00
182 changed files with 14579 additions and 5769 deletions

View File

@@ -69,8 +69,8 @@ jobs:
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")

View File

@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
import { OtpModule } from "./modules/otp/otp.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
@@ -44,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
@@ -99,7 +97,6 @@ import { DriversModule } from './modules/drivers/drivers.module';
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
@@ -128,7 +125,6 @@ import { DriversModule } from './modules/drivers/drivers.module';
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
@@ -143,11 +139,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -158,13 +149,11 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(booking.company?.businessLicense),
businessLicense: this.valueOrDash(
booking.company?.companyProfiles?.[0]?.businessLicense,
),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',

View File

@@ -0,0 +1,103 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
name = "CreateCompanyProfiles1752000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: "freight",
name: "company_profiles",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "gen_random_uuid()",
},
{ name: "company_id", type: "uuid" },
{ name: "type", type: "varchar", length: "32" },
{ name: "reference", type: "varchar", length: "20", isUnique: true },
{
name: "status",
type: "varchar",
length: "32",
default: "'active'",
},
{
name: "business_license",
type: "varchar",
length: "100",
isNullable: true,
},
{ name: "attributes", type: "jsonb", isNullable: true },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
"freight.company_profiles",
new TableForeignKey({
columnNames: ["company_id"],
referencedTableName: "companies",
referencedSchema: "freight",
referencedColumnNames: ["id"],
}),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["company_id"] }),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["type"] }),
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("freight.company_profiles");
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveBusinessLicenseToProfile1752000000001
implements MigrationInterface
{
name = 'MoveBusinessLicenseToProfile1752000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.company_profiles cp
SET business_license = c.business_license
FROM freight.companies c
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
`);
await queryRunner.query(
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
);
await queryRunner.query(`
UPDATE freight.companies c
SET business_license = cp.business_license
FROM (
SELECT DISTINCT ON (cp2.company_id)
cp2.company_id, cp2.business_license
FROM freight.company_profiles cp2
WHERE cp2.business_license IS NOT NULL
ORDER BY cp2.company_id, cp2.created_at
) cp
WHERE cp.company_id = c.id
`);
}
}

View File

@@ -0,0 +1,156 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Full wagon re-seed — runs in this order:
*
* 1. DELETE all existing wagons (hard delete, not soft).
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
* the 5 main operational yards (10 wagons per yard per type):
*
* KALITY — Kality Rail Terminal
* MOJO — Mojo Dry Port
* DIRE_DAWA — Dire Dawa Yard
* DJIB_PORT — Djibouti Port Terminal
* NAGAD — Nagad Terminal, Djibouti
*
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
* Yard IDs are fetched live from freight.yards so the migration is safe across
* all environments regardless of UUID values.
*/
export class SeedWagonsWithYardAssignment1784000000001
implements MigrationInterface
{
name = 'SeedWagonsWithYardAssignment1784000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
await queryRunner.query(`DELETE FROM freight.wagons;`);
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
await queryRunner.query(`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active,
tare_weight_tons
)
VALUES
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
tare_weight_tons = EXCLUDED.tare_weight_tons,
deleted_at = NULL,
updated_at = now();
`);
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
await queryRunner.query(`
DO $$
DECLARE
wt RECORD;
yard_kality UUID;
yard_mojo UUID;
yard_dire_dawa UUID;
yard_djib_port UUID;
yard_nagad UUID;
yards UUID[];
i INT;
yard_id UUID;
wagon_num TEXT;
v_tare NUMERIC;
v_payload NUMERIC;
BEGIN
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
OR yard_djib_port IS NULL OR yard_nagad IS NULL
THEN
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
END IF;
yards := ARRAY[
yard_kality,
yard_mojo,
yard_dire_dawa,
yard_djib_port,
yard_nagad
];
FOR wt IN
SELECT id, code, capacity_tons, tare_weight_tons
FROM freight.wagon_types
WHERE is_active = true
ORDER BY code
LOOP
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
v_payload := COALESCE(wt.capacity_tons, 60.0);
FOR i IN 1 .. 50 LOOP
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
INSERT INTO freight.wagons (
id,
wagon_number,
wagon_type_id,
tare_weight,
max_payload_weight,
status,
current_yard_id,
train_id,
sequence_number,
notes,
train_set_wagon_id,
current_train_schedule_id,
created_at,
updated_at
)
VALUES (
uuid_generate_v4(),
wagon_num,
wt.id,
v_tare,
v_payload,
'Available',
yard_id,
NULL, NULL, NULL, NULL, NULL,
now(), now()
)
ON CONFLICT (wagon_number) DO NOTHING;
END LOOP;
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
END LOOP;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Remove all seeded wagons (full wipe — mirrors what up() did)
await queryRunner.query(`DELETE FROM freight.wagons;`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Day-level booking pool: customers select a DAY (route + day), not a specific
* train. The batch engine's pool query filters bookings on
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
* index backs that scan.
*/
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
}
}

View File

@@ -1,6 +1,5 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
// arrives. The returned status reflects that outcome.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
},
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Same consolidation treatment as the direct submit path.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,

View File

@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
async findByReference(
@Param('reference') reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })

View File

@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
// Only pair bookings the customer has committed (SUBMITTED) or that are
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
// pairing never prematurely submits an unfinished/unpriced draft.
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
/**
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
if (options.trainScheduleId) {
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
* EAT calendar day, regardless of which train they end up on. Same status
* rules and ordering as {@link findBatchPool}, but keyed on
* (origin, destination, day) instead of train_schedule_id — the engine then
* distributes these across all trains departing that day.
*/
findBatchPoolByRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -1,12 +1,17 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -52,6 +57,8 @@ export class BookingsService {
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -146,13 +153,17 @@ export class BookingsService {
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
*
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
* half-empty wagon, so `explicit === false` is ignored when consolidation is
* actually needed. The opt-in flag only matters for cargo that already fills
* whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
@@ -193,10 +204,11 @@ export class BookingsService {
return { booking: paired, messages };
}
if (booking.status === 'DRAFT') {
await this.bookingsRepository.update(booking.id, {
status: 'PENDING_CONSOLIDATION',
} as never);
// No partner yet — park the booking so it waits. Applies both pre-submit
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
// reach this method.
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
@@ -205,45 +217,24 @@ export class BookingsService {
}
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
* waits for a later complementary booking to complete the wagon.
*
* Returns the re-fetched booking, so callers can reflect the resulting status
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
const booking = await this.findById(bookingId);
// Already paired — passes the gate.
// Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
return booking;
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
return result.booking;
}
/** Create a new freight booking. */
@@ -283,8 +274,8 @@ export class BookingsService {
companyId = company.id;
}
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
@@ -300,6 +291,22 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
dto.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
}
const reference = dto.reference || (await this.generateReference());
@@ -575,6 +582,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +595,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +641,109 @@ export class BookingsService {
});
}
/**
* Resolve the company a customer user belongs to, for scoping their own
* bookings. Returns null when no profile/company is linked yet.
*/
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
try {
const { company } =
await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
* to the company the authenticated user is linked to — otherwise it is hidden
* behind a NotFound so booking IDs can't be probed.
*/
async assertCustomerCanAccessBooking(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const companyId = await this.resolveCustomerCompanyId(userId);
if (!companyId || booking.companyId !== companyId) {
// Don't reveal that the booking exists for another company.
throw new NotFoundException(`Booking ${booking.id} not found`);
}
}
/**
* Build the customer-facing shipment tracking payload for a booking from the
* train schedule it is assigned to and the live checkpoint log. The caller is
* responsible for authorizing access to the booking first.
*
* When the booking has not been assigned to a train yet, returns a valid
* "no schedule" payload so the UI can show a pre-dispatch state.
*/
async getBookingTracking(
bookingId: string,
): Promise<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: false,
scheduleId: null,
trainNumber: null,
scheduleStatus: null,
direction: null,
origin: null,
destination: null,
stations: [],
checkpoints: [],
currentSequenceNo: -1,
actualDepartureAt: null,
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
};
if (!booking.trainScheduleId) {
return empty;
}
// Pull the live corridor + checkpoints for the assigned schedule. If the
// schedule was removed, fall back to the pre-dispatch state rather than 500.
let track: Awaited<
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
>;
try {
track = await this.trainSchedulingService.getScheduleCheckpoints(
booking.trainScheduleId,
);
} catch {
return empty;
}
return {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: true,
scheduleId: track.scheduleId,
trainNumber: track.trainNumber,
scheduleStatus: track.status as Freight.TrainScheduleStatus,
direction: track.direction,
origin: track.origin,
destination: track.destination,
stations: track.stations,
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
currentSequenceNo: track.currentSequenceNo,
actualDepartureAt: track.actualDepartureAt,
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
};
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;

View File

@@ -91,12 +91,21 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
/** Target schedule this booking is created against (required by the backoffice create form). */
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
/**
* Staff-only manual pin to a specific train. Customers omit this — they pick a
* DAY via {@link scheduledDate} and the batch engine assigns a train within
* that (route, day) pool. When provided, the schedule must be OPEN and on the
* booking route.
*/
@ApiPropertyOptional({
format: 'uuid',
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;

View File

@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
/**
* The train this booking is assigned to. FK to train_schedules.
*
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
* this stays null at creation. The batch engine sets it when it assigns the
* booking to a specific train within its (route, day) pool; staff may also
* pin it manually. The day-level pool is keyed on
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
*/
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;

View File

@@ -1,22 +1,38 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFiles,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards";
import { FilesService } from "../files/files.service";
import { CompaniesService } from "./companies.service";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
interface CurrentIamUser {
id: string;
@@ -25,36 +41,47 @@ interface CurrentIamUser {
phoneNumber?: string;
}
@ApiTags('Companies')
@Controller('companies')
@ApiTags("Companies")
@Controller("companies")
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
) { }
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("profile")
@ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile(
@CurrentUser() user: CurrentIamUser,
): Promise<ProfileResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
@Get("dashboard")
@ApiOperation({
summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
})
async getDashboard(
@CurrentUser() user: CurrentIamUser,
): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
@Patch("profile")
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
@@ -62,145 +89,153 @@ export class CompaniesController {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
@Post("company-profiles")
@ApiOperation({
summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
})
async addCompanyProfiles(
@CurrentUser() user: CurrentIamUser,
@Body() dto: AddCompanyProfilesDto,
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
// Used by portal
@Post("create")
@ApiOperation({
summary:
"Create a company with its associated external profile (onboarding)",
})
async createWithProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyWithProfileDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? '').split(' ');
const { profile, company } = await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || '',
lastName: nameParts.slice(-1)[0] || '',
email: user.email ?? '',
phone: user.phoneNumber ?? '',
},
dto,
);
const nameParts = (user.name?.en ?? "").split(" ");
const { profile, company } =
await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email ?? "",
phone: user.phoneNumber ?? "",
},
dto,
);
return new CompanyInfoResponseDto(profile, company);
}
// Used by backoffice
@Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
@ApiOperation({
summary:
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
})
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
return new ResponseCompanyDto(company);
}
@Get()
@ApiOperation({ summary: 'List all companies' })
@ApiOperation({ summary: "List all companies" })
async findAll(): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
@Get('type/:type')
@ApiOperation({ summary: 'Find companies by type' })
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
@Get("type/:type")
@ApiOperation({ summary: "Find companies by type" })
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
return companies
.filter((c) => c.type === type)
.map((c) => new ResponseCompanyDto(c));
}
@Get('search')
@ApiOperation({ summary: 'Search companies by name' })
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
@Get("search")
@ApiOperation({ summary: "Search companies by name" })
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
}
@Get(':id')
@ApiOperation({ summary: 'Get company by ID' })
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
@Get(":id")
@ApiOperation({ summary: "Get company by ID" })
async findById(
@Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company);
}
@Patch(':id')
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' })
@ApiOperation({ summary: "Update a company" })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(':id')
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' })
@ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/documents')
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
return this.filesService.uploadMany(companyId, "companies", files);
}
@Post(':companyId/profiles')
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
@ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: CreateExternalProfileDto,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.createProfile({ ...dto, companyId });
const profile = await this.companiesService.createProfile({
...dto,
companyId,
});
return new ResponseExternalProfileDto(profile);
}
@Get(':companyId/profiles')
@ApiOperation({ summary: 'List profiles for a company' })
@Get(":companyId/profiles")
@ApiOperation({ summary: "List profiles for a company" })
async listProfiles(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<ResponseExternalProfileDto[]> {
const profiles = await this.companiesService.findProfilesByCompany(companyId);
const profiles =
await this.companiesService.findProfilesByCompany(companyId);
return profiles.map((p) => new ResponseExternalProfileDto(p));
}
@Get('profile/user/:userId')
@ApiOperation({ summary: 'Get profile by IAM user ID' })
@Get("profile/user/:userId")
@ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser(
@Param('userId', ParseUUIDPipe) userId: string,
@Param("userId", ParseUUIDPipe) userId: string,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.findProfileByUserId(userId);
return new ResponseExternalProfileDto(profile);
}
@Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
return new ResponseFFClientDto(client);
}
@Get(':forwarderCompanyId/clients')
@ApiOperation({ summary: 'List clients of a forwarder' })
async listFFClients(
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
): Promise<ResponseFFClientDto[]> {
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
return clients.map((c) => new ResponseFFClientDto(c));
}
@Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteFFClient(id);
}
}

View File

@@ -1,21 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
FilesModule,
],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
providers: [
CompaniesService,
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
CompanyDashboardRepository,
],
exports: [CompaniesService],
})
export class CompaniesModule {}
export class CompaniesModule { }

View File

@@ -1,19 +1,27 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
CompanyProfile,
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
export interface UserIdentity {
userId: string;
@@ -27,10 +35,10 @@ export interface UserIdentity {
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
) { }
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
@@ -40,27 +48,33 @@ export class CompaniesService {
return this.companiesRepo.create(dto);
}
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
async createCompanyWithProfile(
identity: UserIdentity,
dto: CreateCompanyWithProfileDto,
): Promise<{ company: Company; profile: ExternalProfile }> {
if (dto.tin) {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
throw new ConflictException(
`Company with TIN ${dto.tin} already exists`,
);
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
if (existingProfile) {
throw new ConflictException(`Profile with email ${identity.email} already exists`);
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
tin: dto.tin ?? '',
tin: dto.tin ?? "",
vatNumber: dto.vatNumber ?? null,
businessLicense: dto.fanNumber ?? null,
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? 'Ethiopia',
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
email: dto.companyEmail ?? null,
@@ -78,11 +92,39 @@ export class CompaniesService {
isPrimaryContact: dto.isPrimaryContact ?? true,
});
// Persist the operational role(s) chosen during onboarding. Types are
// already constrained to the company type on the client; any that don't
// match are skipped defensively rather than failing the whole signup.
if (dto.companyProfiles?.length) {
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const input of dto.companyProfiles) {
if (!allowedTypes.includes(input.type)) continue;
const existing = await this.companyProfilesRepo.findByType(
company.id,
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
}
return { company, profile };
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
async findCompanyById(id: string): Promise<Company> {
@@ -91,12 +133,21 @@ export class CompaniesService {
return company;
}
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const company = profile.company;
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
if (!company)
throw new NotFoundException(
`Company for profile ${profile.id} not found`,
);
company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(company.id);
return { profile, company };
}
@@ -114,7 +165,9 @@ export class CompaniesService {
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
async getDashboardSummary(
userId: string,
): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
@@ -125,7 +178,9 @@ export class CompaniesService {
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
const prevYearToDate = new Date(
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
);
const [
deliveredThis,
@@ -139,20 +194,36 @@ export class CompaniesService {
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.sumPaidSpendByCurrency(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
this.dashboardRepo.sumCommittedTonnage(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
companyId,
this.monthsAgo(now, 5),
now,
),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
const spendPrev =
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
completionRate:
committedThis > 0
? Math.round((deliveredThis / committedThis) * 100)
: 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
@@ -172,12 +243,12 @@ export class CompaniesService {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: 'ETB',
spendCurrency: "ETB",
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: 'ETB',
currency: "ETB",
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
@@ -190,8 +261,11 @@ export class CompaniesService {
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
if (totals.length === 0) return { currency: 'ETB', total: 0 };
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
currency: string;
total: number;
} {
if (totals.length === 0) return { currency: "ETB", total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
@@ -206,13 +280,29 @@ export class CompaniesService {
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const labels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
series.push({
month: labels[d.getMonth()],
tonnes: Math.round(byKey.get(key) ?? 0),
});
}
return series;
}
@@ -224,7 +314,10 @@ export class CompaniesService {
return updated;
}
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
@@ -233,30 +326,38 @@ export class CompaniesService {
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
@@ -270,7 +371,9 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException(`Profile with email ${dto.email} already exists`);
throw new ConflictException(
`Profile with email ${dto.email} already exists`,
);
}
return this.profilesRepo.create(dto);
@@ -278,7 +381,8 @@ export class CompaniesService {
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
return profile;
}
@@ -286,28 +390,119 @@ export class CompaniesService {
return this.profilesRepo.findByCompanyId(companyId);
}
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
await this.findCompanyById(dto.forwarderCompanyId);
await this.findCompanyById(dto.clientCompanyId);
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
switch (companyType) {
case "customer":
return [ProfileType.importer, ProfileType.exporter];
case "freight_forwarder":
return [ProfileType.freightForwarder];
case "dj_freight_forwarder":
return [ProfileType.djFreightForwarder];
case "transporter":
return [ProfileType.transporter];
default:
return [];
}
}
const existing = await this.ffClientsRepo.findRelationship(
dto.forwarderCompanyId,
dto.clientCompanyId,
);
if (existing) {
throw new ConflictException('This forwarder-client relationship already exists');
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
): Promise<CompanyProfile> {
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const type = profileType ?? allowedTypes[0];
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
return this.ffClientsRepo.create(dto);
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
async createDefaultProfilesForCompany(
companyId: string,
): Promise<CompanyProfile[]> {
const company = await this.findCompanyById(companyId);
const types = this.getProfileTypeForCompanyType(company.type);
const profiles: CompanyProfile[] = [];
for (const type of types) {
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (!existing) {
profiles.push(await this.createCompanyProfile(companyId, type));
}
}
if (profiles.length === 0) {
throw new BadRequestException(
`Company of type "${company.type}" must have at least one operational profile`,
);
}
return profiles;
}
async deleteFFClient(id: string): Promise<void> {
const client = await this.ffClientsRepo.findById(id);
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
await this.ffClientsRepo.softDelete(id);
/**
* Add operational profile(s) to the current user's company (portal settings).
* Add-only and idempotent: each requested type must be allowed for the
* company's type, profiles that already exist are skipped (not re-created or
* rejected), and the full updated list is returned.
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
): Promise<CompanyProfile[]> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const type of types) {
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
return this.companyProfilesRepo.findByCompanyId(companyId);
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
const SEQUENCE_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "seq_company_profile_ex",
[ProfileType.importer]: "seq_company_profile_im",
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
[ProfileType.transporter]: "seq_company_profile_tr",
};
const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "EX",
[ProfileType.importer]: "IM",
[ProfileType.freightForwarder]: "FFE",
[ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR",
};
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@InjectRepository(CompanyProfile)
repo: Repository<CompanyProfile>,
) {
super(repo);
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
return this.repository.find({
where: { companyId },
relations: ["company"],
});
}
async findByType(
companyId: string,
type: ProfileType,
): Promise<CompanyProfile | null> {
return this.repository.findOne({
where: { companyId, type },
});
}
async findByReference(reference: string): Promise<CompanyProfile | null> {
return this.repository.findOne({ where: { reference } });
}
}

View File

@@ -0,0 +1,9 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
}

View File

@@ -1,5 +1,17 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
}
export class CreateCompanyWithProfileDto {
@IsEnum(CompanyType)
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
attributes?: Record<string, any>;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CompanyProfileInputDto)
companyProfiles?: CompanyProfileInputDto[];
}

View File

@@ -25,11 +25,6 @@ export class CreateCompanyDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
@IsOptional()
@IsString()
@MaxLength(32)

View File

@@ -1,24 +0,0 @@
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { FFClientRelationship } from '../entities/ff-client.entity';
export class CreateFFClientDto {
@IsUUID()
@IsNotEmpty()
forwarderCompanyId!: string;
@IsUUID()
@IsNotEmpty()
clientCompanyId!: string;
@IsOptional()
@IsEnum(FFClientRelationship)
relationshipType?: FFClientRelationship;
@IsOptional()
@IsBoolean()
canBookOnBehalf?: boolean;
@IsOptional()
@IsBoolean()
canViewDocuments?: boolean;
}

View File

@@ -1,9 +1,11 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
vatNumber: string | null;
fanNumber: string | null;
companyProfiles: ResponseCompanyProfileDto[];
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;

View File

@@ -1,6 +1,29 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { CompanyProfile } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyProfileDto {
id: string;
type: string;
reference: string;
status: string;
businessLicense?: string | null;
attributes?: Record<string, any> | null;
createdAt: Date;
updatedAt: Date;
constructor(profile: CompanyProfile) {
this.id = profile.id;
this.type = profile.type;
this.reference = profile.reference;
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.attributes = profile.attributes;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}
}
export class ResponseCompanyDto {
id: string;
name: string;
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
businessLicense?: string | null;
fanNumber?: string | null;
country: string;
address?: string | null;
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
website?: string | null;
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
createdAt: Date;
updatedAt: Date;
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
this.status = company.status;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.businessLicense = company.businessLicense;
this.fanNumber = company.fanNumber;
this.country = company.country;
this.address = company.address;
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,23 +0,0 @@
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
export class ResponseFFClientDto {
id: string;
forwarderCompanyId: string;
clientCompanyId: string;
relationshipType: FFClientRelationship;
canBookOnBehalf: boolean;
canViewDocuments: boolean;
createdAt: Date;
updatedAt: Date;
constructor(client: FFClient) {
this.id = client.id;
this.forwarderCompanyId = client.forwarderCompanyId;
this.clientCompanyId = client.clientCompanyId;
this.relationshipType = client.relationshipType;
this.canBookOnBehalf = client.canBookOnBehalf;
this.canViewDocuments = client.canViewDocuments;
this.createdAt = client.createdAt;
this.updatedAt = client.updatedAt;
}
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFFClientDto } from './create-ff-client.dto';
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
export enum ProfileType {
importer = "importer",
exporter = "exporter",
freightForwarder = "freight_forwarder",
djFreightForwarder = "dj_freight_forwarder",
transporter = "transporter",
}
export enum ProfileStatus {
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@Index(["companyId"])
export class CompanyProfile extends BaseEntity {
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company, (company) => company.companyProfiles)
@JoinColumn({ name: "company_id" })
company!: Company;
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
})
reference!: string;
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
})
status!: ProfileStatus;
@Column({
name: "business_license",
type: "varchar",
length: 100,
nullable: true,
})
businessLicense?: string | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
}

View File

@@ -1,79 +1,110 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { ExternalProfile } from './external-profile.entity';
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, OneToMany } from "typeorm";
import { ExternalProfile } from "./external-profile.entity";
import { CompanyProfile } from "./company-profile.entity";
export enum CompanyType {
Customer = 'customer',
Forwarder = 'forwarder',
Transporter = 'transporter',
Broker = 'broker',
Customer = "customer",
FreightForwarder = "freight_forwarder",
DJFreightForwarder = "dj_freight_forwarder",
Transporter = "transporter",
}
export enum CompanyStatus {
Active = 'active',
Pending = 'pending',
Suspended = 'suspended',
Blacklisted = 'blacklisted',
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: 'freight', name: 'companies' })
@Index(['tin'])
@Index(['type'])
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
export class Company extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 200 })
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
@Column({
name: "status",
type: "varchar",
length: 32,
default: CompanyStatus.Pending,
})
status!: CompanyStatus;
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
businessLicense?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
fanNumber?: string | null;
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
@Column({ name: 'address', type: 'text', nullable: true })
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
email?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "contact_person_name",
type: "varchar",
length: 100,
nullable: true,
})
contactPersonName?: string | null;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "contact_person_phone",
type: "varchar",
length: 20,
nullable: true,
})
contactPersonPhone?: string | null;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "general_manager_name",
type: "varchar",
length: 100,
nullable: true,
})
generalManagerName?: string | null;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
@Column({
name: "general_manager_email",
type: "varchar",
length: 150,
nullable: true,
})
generalManagerEmail?: string | null;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "general_manager_phone",
type: "varchar",
length: 20,
nullable: true,
})
generalManagerPhone?: string | null;
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];
@OneToMany(() => CompanyProfile, (profile) => profile.company)
companyProfiles?: CompanyProfile[];
}

View File

@@ -1,37 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Company } from './company.entity';
export enum FFClientRelationship {
ManagedAccount = 'managed_account',
SubAgent = 'sub_agent',
}
@Entity({ schema: 'freight', name: 'ff_clients' })
@Unique(['forwarderCompanyId', 'clientCompanyId'])
@Index(['forwarderCompanyId'])
@Index(['clientCompanyId'])
export class FFClient extends BaseEntity {
@Column({ name: 'forwarder_company_id', type: 'uuid' })
forwarderCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'forwarder_company_id' })
forwarderCompany!: Company;
@Column({ name: 'client_company_id', type: 'uuid' })
clientCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'client_company_id' })
clientCompany!: Company;
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
relationshipType!: FFClientRelationship;
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
canBookOnBehalf!: boolean;
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
canViewDocuments!: boolean;
}

View File

@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { FFClient } from './entities/ff-client.entity';
@Injectable()
export class FFClientRepository extends BaseRepository<FFClient> {
constructor(
@InjectRepository(FFClient)
repo: Repository<FFClient>,
) {
super(repo);
}
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { forwarderCompanyId } as any });
}
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { clientCompanyId } as any });
}
async findRelationship(
forwarderCompanyId: string,
clientCompanyId: string,
): Promise<FFClient | null> {
return this.repository.findOne({
where: { forwarderCompanyId, clientCompanyId } as any,
});
}
}

View File

@@ -1,86 +0,0 @@
// src/modules/customers/customers.controller.ts
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Body,
Query,
} from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Controller("customers")
@FreightAdmin()
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
return this.customersService.create(createCustomerDto);
}
@Get()
findAll(): Promise<Customer[]> {
return this.customersService.findAll();
}
@Get("stats")
@ApiOperation({ summary: "Get customer statistics" })
getStats(): Promise<{ total: number; withVatNumber: number }> {
return this.customersService.getStats();
}
@Get("search")
searchByName(@Query("name") name: string): Promise<Customer[]> {
return this.customersService.searchByName(name);
}
@Get("email/:email")
findByEmail(@Param("email") email: string): Promise<Customer> {
return this.customersService.findByEmail(email);
}
@Get("vat/:vatNumber")
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
return this.customersService.findByVatNumber(vatNumber);
}
@Get(":id")
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
return this.customersService.findById(id);
}
// @Get("user/:userId")
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
// return this.customersService.findByUserId(userId);
// }
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
): Promise<Customer> {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
return this.customersService.delete(id);
}
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersController } from "./customers.controller";
import { CustomersRepository } from "./customers.repository";
import { CustomersService } from "./customers.service";
import { Customer } from "./entities/customer.entity";
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService],
})
export class CustomersModule {}

View File

@@ -1,117 +0,0 @@
// import { BaseRepository } from "@edr/api-common";
// import { EntityRepository } from "typeorm";
// src/modules/customers/customers.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
import { Customer } from "./entities/customer.entity";
import { CreateCustomerDto } from "./dto/create-customer.dto";
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
@Injectable()
export class CustomersRepository {
constructor(
@InjectRepository(Customer)
private readonly repository: Repository<Customer>,
) { }
async create(dto: CreateCustomerDto): Promise<Customer> {
const customer = this.repository.create(dto);
return await this.repository.save(customer);
}
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
return await this.repository.find(options);
}
async findById(id: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
}
async findByUserId(userId: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
}
async findByEmail(email: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
}
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
}
async findByName(name: string): Promise<Customer[]> {
return await this.repository
.createQueryBuilder("customer")
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
.getMany();
}
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
if (!email && !vatNumber) return null;
const queryBuilder = this.repository.createQueryBuilder('customer');
if (email && vatNumber) {
queryBuilder.where('customer.email = :email', { email })
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
} else if (email) {
queryBuilder.where('customer.email = :email', { email });
} else if (vatNumber) {
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
}
return await queryBuilder.getOne();
}
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
await this.repository.update(id, updates);
return this.findById(id);
}
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete(id);
return (result.affected ?? 0) > 0;
}
async count(where?: any): Promise<number> {
if (where?.createdAt) {
const result = await this.repository
.createQueryBuilder('customer')
.where('customer.createdAt >= :date', { date: where.createdAt })
.getCount();
return result;
}
return await this.repository.count();
}
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
const queryBuilder = this.repository.createQueryBuilder('customer')
.where('customer.email = :email', { email });
if (vatNumber) {
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
}
const count = await queryBuilder.getCount();
return count > 0;
}
async countWithVatNumber(): Promise<number> {
const count = await this.repository
.createQueryBuilder('customer')
.where('customer.vatNumber IS NOT NULL')
.andWhere("customer.vatNumber != ''")
.getCount();
return count;
}
getRepository(): Repository<Customer> {
return this.repository;
}
softDelete(id: string): any {
return id;
}
}

View File

@@ -1,140 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new customer */
async create(dto: CreateCustomerDto): Promise<Customer> {
const exists = await this.customersRepository.existsByUniqueFields(
dto.email,
dto.vatNumber,
);
if (exists) {
throw new ConflictException(
"Customer with same email or VAT number already exists",
);
}
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
return this.customersRepository.create(dto);
}
/** Get all customers */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({
order: { companyName: "ASC" },
});
}
/** Get customer by ID */
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer with ID ${id} not found`);
}
return customer;
}
// async findByUserId(userId: string): Promise<Customer> {
// const customer = await this.customersRepository.findByUserId(userId);
// if (!customer) {
// throw new NotFoundException(`Customer with ID ${userId} not found`);
// }
// return customer;
//}
/** Get customer by email */
async findByEmail(email: string): Promise<Customer> {
const customer = await this.customersRepository.findByEmail(email);
if (!customer) {
throw new NotFoundException(`Customer with email ${email} not found`);
}
return customer;
}
/** Get customer by VAT number */
async findByVatNumber(vatNumber: string): Promise<Customer> {
const customer = await this.customersRepository.findByVatNumber(vatNumber);
if (!customer) {
throw new NotFoundException(
`Customer with VAT number ${vatNumber} not found`,
);
}
return customer;
}
/** Search customers by name */
searchByName(name: string): Promise<Customer[]> {
return this.customersRepository.findByName(name);
}
/** Update customer */
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
// Validate VAT number if provided
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
// // Check email conflict
// if (dto.email) {
// const existing = await this.customersRepository.findByEmail(dto.email);
// // if (existing && existing.userId !== id) {
// // throw new ConflictException(
// // `Customer with email "${dto.email}" already exists`,
// // );
// // }
// }
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
/** Delete customer (soft delete) */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
/** Get customer statistics */
async getStats(): Promise<{ total: number; withVatNumber: number }> {
const total = await this.customersRepository.count();
const withVatNumber = await this.customersRepository.countWithVatNumber();
return { total, withVatNumber };
}
delete(id: string): any {
return id;
}
}

View File

@@ -1,156 +0,0 @@
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
IsNotEmpty,
Length,
Matches,
} from "class-validator";
// Enums
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
// DTO
export class CreateCustomerDto {
// Basic identity
@IsString()
@IsNotEmpty()
userId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
phone!: string;
// Company info
@IsString()
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsEmail()
@IsNotEmpty()
companyEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
companyPhone!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
companyLocation!: string;
@IsString()
@IsNotEmpty()
companyAddress!: string;
// Classification
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
// Legal identifiers
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
tinNumber!: string;
@IsString()
@IsNotEmpty()
@Length(16, 16)
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
fanNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
vatNumber!: string;
// Contact person
@IsString()
@IsNotEmpty()
@MaxLength(100)
contactPersonName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
contactPersonPhone!: string;
// Management
@IsString()
@IsNotEmpty()
@MaxLength(100)
generalManagerName!: string;
@IsEmail()
@IsNotEmpty()
generalManagerEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
generalManagerPhone!: string;
// POA (Power of Attorney)
@IsOptional()
@IsString()
@MaxLength(100)
poaName?: string;
@IsOptional()
@IsString()
@MaxLength(20)
poaPhone?: string;
@IsOptional()
@IsString()
poaAddress?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
@MaxLength(100)
poaLocation?: string;
// Extra
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -1,60 +0,0 @@
// src/modules/customers/dto/response-customer.dto.ts
import { Customer } from '../entities/customer.entity';
export class ResponseCustomerDto {
//UserId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber?: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
createdAt: Date;
updatedAt: Date;
constructor(customer: Customer) {
//this.UserId = customer.userId;
this.firstName = customer.firstName;
this.lastName = customer.lastName;
this.email = customer.email;
this.phone = customer.phone;
this.companyName = customer.companyName;
this.companyEmail = customer.companyEmail;
this.companyPhone = customer.companyPhone;
this.companyLocation = customer.companyLocation;
this.companyAddress = customer.companyAddress;
this.contactPersonName = customer.contactPersonName;
this.contactPersonPhone = customer.contactPersonPhone;
this.tinNumber = customer.tinNumber;
this.vatNumber = customer.vatNumber ?? undefined;
this.fanNumber = customer.fanNumber;
this.generalManagerName = customer.generalManagerName;
this.generalManagerEmail = customer.generalManagerEmail;
this.generalManagerPhone = customer.generalManagerPhone;
this.poaName = customer.poaName ?? '';
this.poaPhone = customer.poaPhone ?? '';
this.poaAddress = customer.poaAddress ?? '';
this.poaEmail = customer.poaEmail ?? '';
this.poaLocation = customer.poaLocation ?? '';
this.notes = customer.notes ?? '';
this.createdAt = customer.createdAt;
this.updatedAt = customer.updatedAt;
}
}

View File

@@ -1,9 +0,0 @@
// src/modules/customers/dto/update-customer.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCustomerDto } from './create-customer.dto';
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
email?: string;
vatNumber?: string;
// Add any other properties you need to access directly
}

View File

@@ -1,87 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'customers' })
@Index(['email'])
//@Index(['userId'])
@Index(['tinNumber'])
@Index(['fanNumber'])
export class Customer extends BaseEntity {
//@Column({ name: 'user_id', type: 'uuid' })
//userId!: string;
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 20 })
phone!: string;
@Column({ name: 'company_name', type: 'varchar', length: 200 })
companyName!: string;
@Column({ name: 'company_email', type: 'varchar', length: 150 })
companyEmail!: string;
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
companyPhone!: string;
@Column({ name: 'company_location', type: 'varchar', length: 100 })
companyLocation!: string;
@Column({ name: 'company_address', type: 'text' })
companyAddress!: string;
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
customerType?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
status?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
contactPersonName!: string;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
contactPersonPhone!: string;
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
tinNumber!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
fanNumber!: string;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
generalManagerName!: string;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
generalManagerEmail!: string;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
generalManagerPhone!: string;
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
poaName?: string | null;
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
poaPhone?: string | null;
@Column({ name: 'poa_address', type: 'text', nullable: true })
poaAddress?: string | null;
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
poaEmail?: string | null;
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
poaLocation?: string | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -1,25 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { OverviewController } from './overview.controller';
import { OverviewRepository } from './overview.repository';
import { OverviewService } from './overview.service';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { Company } from "../companies/entities/company.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import { OverviewController } from "./overview.controller";
import { OverviewRepository } from "./overview.repository";
import { OverviewService } from "./overview.service";
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
PaymentEntity,
Customer,
Company,
Train,
Wagon,
Container,
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],
})
export class OverviewModule {}
export class OverviewModule { }

View File

@@ -1,24 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Freight } from '@edr/types';
import { Repository, ObjectLiteral } from 'typeorm';
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Freight } from "@edr/types";
import { Repository, ObjectLiteral } from "typeorm";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from './overview.constants';
} from "./overview.constants";
import { Company } from "../companies/entities/company.entity";
export type OverviewBookingKpisRow = {
totalActive: number;
@@ -46,8 +46,8 @@ export class OverviewRepository {
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository<PaymentEntity>,
@InjectRepository(Customer)
private readonly customerRepository: Repository<Customer>,
@InjectRepository(Company)
private readonly companyRepository: Repository<Company>,
@InjectRepository(Train)
private readonly trainRepository: Repository<Train>,
@InjectRepository(Wagon)
@@ -60,32 +60,32 @@ export class OverviewRepository {
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
) { }
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
const row = await this.bookingRepository
.createQueryBuilder('booking')
.createQueryBuilder("booking")
.select(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
'totalActive',
"totalActive",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
'needsAction',
"needsAction",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
'urgent',
"urgent",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
'inApproval',
"inApproval",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
'submittedToday',
"submittedToday",
)
.where('booking.deleted_at IS NULL')
.where("booking.deleted_at IS NULL")
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -112,9 +112,9 @@ export class OverviewRepository {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
this.trainRepository
.createQueryBuilder('train')
.where('train.deleted_at IS NULL')
.andWhere('train.status IN (:...statuses)', {
.createQueryBuilder("train")
.where("train.deleted_at IS NULL")
.andWhere("train.status IN (:...statuses)", {
statuses: [
Freight.TrainStatus.InService,
Freight.TrainStatus.Scheduled,
@@ -122,39 +122,46 @@ export class OverviewRepository {
})
.getCount(),
this.wagonRepository
.createQueryBuilder('wagon')
.where('wagon.deleted_at IS NULL')
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
.createQueryBuilder("wagon")
.where("wagon.deleted_at IS NULL")
.andWhere("wagon.status = :status", {
status: Freight.WagonStatus.Available,
})
.getCount(),
this.containerRepository
.createQueryBuilder('container')
.where('container.deleted_at IS NULL')
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
.createQueryBuilder("container")
.where("container.deleted_at IS NULL")
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
.getCount(),
this.cargoRepository
.createQueryBuilder('cargo')
.where('cargo.deleted_at IS NULL')
.andWhere('cargo.status IN (:...statuses)', {
statuses: ['LOADED', 'IN_TRANSIT'],
.createQueryBuilder("cargo")
.where("cargo.deleted_at IS NULL")
.andWhere("cargo.status IN (:...statuses)", {
statuses: ["LOADED", "IN_TRANSIT"],
})
.getCount(),
]);
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
return {
trainsActive,
wagonsAvailable,
containersInTransit,
cargoesLoaded,
};
}
async getCustomerKpis(): Promise<{
totalCustomers: number;
newCustomersThisMonth: number;
}> {
const row = await this.customerRepository
.createQueryBuilder('customer')
.select('COUNT(*)::int', 'totalCustomers')
const row = await this.companyRepository
.createQueryBuilder("customer")
.select("COUNT(*)::int", "totalCustomers")
.addSelect(
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
'newCustomersThisMonth',
"newCustomersThisMonth",
)
.where('customer.deleted_at IS NULL')
.where("customer.deleted_at IS NULL")
.getRawOne<Record<string, string>>();
return {
@@ -170,26 +177,26 @@ export class OverviewRepository {
successfulPaymentsMtd: number;
}> {
const revenueRow = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'revenueMtdEtb',
"revenueMtdEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'revenueMtdUsd',
"revenueMtdUsd",
)
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
.where('payment.status = :status', { status: 'success' })
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.getRawOne<Record<string, string>>();
const pendingPayments = await this.paymentRepository
.createQueryBuilder('payment')
.where('payment.status IN (:...statuses)', {
statuses: ['action-required', 'processing'],
.createQueryBuilder("payment")
.where("payment.status IN (:...statuses)", {
statuses: ["action-required", "processing"],
})
.getCount();
@@ -201,7 +208,10 @@ export class OverviewRepository {
};
}
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
async getStaffKpis(): Promise<{
activeEmployees: number;
activeUsers: number;
}> {
const [activeEmployees, activeUsers] = await Promise.all([
this.employeeRepository.count({
where: { isCurrent: true },
@@ -217,15 +227,17 @@ export class OverviewRepository {
return { activeEmployees, activeUsers };
}
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
async getBookingTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('booking.created_at::date')
.orderBy('booking.created_at::date', 'ASC')
.groupBy("booking.created_at::date")
.orderBy("booking.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -236,11 +248,11 @@ export class OverviewRepository {
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.createQueryBuilder("booking")
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
@@ -252,26 +264,26 @@ export class OverviewRepository {
days: number,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
'date',
"date",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'amountUsd',
"amountUsd",
)
.where('payment.status = :status', { status: 'success' })
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
@@ -283,18 +295,18 @@ export class OverviewRepository {
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select('booking.id', 'id')
.addSelect('booking.reference', 'reference')
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
.addSelect('booking.status', 'status')
.addSelect('booking.priority_score', 'priorityScore')
.addSelect('booking.total_amount', 'totalAmount')
.addSelect('booking.payment_currency', 'paymentCurrency')
.addSelect('booking.created_at', 'createdAt')
.where('booking.deleted_at IS NULL')
.orderBy('booking.created_at', 'DESC')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select("booking.id", "id")
.addSelect("booking.reference", "reference")
.addSelect("COALESCE(company.name, '—')", "customerLabel")
.addSelect("booking.status", "status")
.addSelect("booking.priority_score", "priorityScore")
.addSelect("booking.total_amount", "totalAmount")
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
id: string;
@@ -319,15 +331,17 @@ export class OverviewRepository {
}));
}
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
async getBookingsByFreightType(): Promise<
{ label: string; count: number }[]
> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.freight_type', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.freight_type')
.orderBy('count', 'DESC')
.groupBy("booking.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -338,13 +352,13 @@ export class OverviewRepository {
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.payment_currency', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.payment_currency')
.orderBy('count', 'DESC')
.groupBy("booking.payment_currency")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -355,11 +369,11 @@ export class OverviewRepository {
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('payment.status')
.orderBy('count', 'DESC')
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -372,20 +386,25 @@ export class OverviewRepository {
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.method', 'method')
.addSelect('COUNT(*)::int', 'count')
.createQueryBuilder("payment")
.select("payment.method", "method")
.addSelect("COUNT(*)::int", "count")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
'amountUsd',
"amountUsd",
)
.groupBy('payment.method')
.orderBy('count', 'DESC')
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
.groupBy("payment.method")
.orderBy("count", "DESC")
.getRawMany<{
method: string;
count: string;
amountEtb: string;
amountUsd: string;
}>();
return rows.map((row) => ({
method: row.method,
@@ -395,16 +414,18 @@ export class OverviewRepository {
}));
}
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
async getRevenueByCurrency(): Promise<
{ currency: string; amount: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.currency', 'currency')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
.where('payment.status = :status', { status: 'success' })
.createQueryBuilder("payment")
.select("payment.currency", "currency")
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.groupBy('payment.currency')
.groupBy("payment.currency")
.getRawMany<{ currency: string; amount: string }>();
return rows.map((row) => ({
@@ -413,20 +434,28 @@ export class OverviewRepository {
}));
}
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.trainRepository, 'train');
async getTrainStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.trainRepository, "train");
}
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.wagonRepository, 'wagon');
async getWagonStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.wagonRepository, "wagon");
}
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.containerRepository, 'container');
async getContainerStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.containerRepository, "container");
}
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.cargoRepository, 'cargo');
async getCargoStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.cargoRepository, "cargo");
}
private async statusBreakdown(
@@ -435,11 +464,11 @@ export class OverviewRepository {
): Promise<{ status: string; count: number }[]> {
const rows = await repository
.createQueryBuilder(alias)
.select(`${alias}.status`, 'status')
.addSelect('COUNT(*)::int', 'count')
.select(`${alias}.status`, "status")
.addSelect("COUNT(*)::int", "count")
.where(`${alias}.deleted_at IS NULL`)
.groupBy(`${alias}.status`)
.orderBy('count', 'DESC')
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -448,15 +477,19 @@ export class OverviewRepository {
}));
}
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('customer.created_at::date')
.orderBy('customer.created_at::date', 'ASC')
async getCustomerGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("customer.created_at::date")
.orderBy("customer.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -466,13 +499,16 @@ export class OverviewRepository {
}
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.groupBy('customer.customer_type')
.orderBy('count', 'DESC')
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
"label",
)
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.groupBy("customer.type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -481,16 +517,18 @@ export class OverviewRepository {
}));
}
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
async getTopCustomersByBookings(
limit: number,
): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select(`COALESCE(company.name, 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select(`COALESCE(company.name, 'Unknown')`, "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('company.name')
.orderBy('count', 'DESC')
.groupBy("company.name")
.orderBy("count", "DESC")
.limit(limit)
.getRawMany<{ label: string; count: string }>();
@@ -502,11 +540,11 @@ export class OverviewRepository {
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.userRepository
.createQueryBuilder('user')
.select('user.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('user.status')
.orderBy('count', 'DESC')
.createQueryBuilder("user")
.select("user.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("user.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -515,15 +553,19 @@ export class OverviewRepository {
}));
}
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
async getEmployeeGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.employeeRepository
.createQueryBuilder('employee')
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('employee.is_current = true')
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('employee.created_at::date')
.orderBy('employee.created_at::date', 'ASC')
.createQueryBuilder("employee")
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("employee.is_current = true")
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("employee.created_at::date")
.orderBy("employee.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -538,16 +580,16 @@ export class OverviewRepository {
where: { isActive: true, status: EUserStatus.ACCEPTED },
}),
this.userRepository
.createQueryBuilder('user')
.where('user.is_active = false OR user.status != :status', {
.createQueryBuilder("user")
.where("user.is_active = false OR user.status != :status", {
status: EUserStatus.ACCEPTED,
})
.getCount(),
]);
return [
{ label: 'Active', count: active },
{ label: 'Inactive', count: inactive },
{ label: "Active", count: active },
{ label: "Inactive", count: inactive },
];
}
}

View File

@@ -18,7 +18,8 @@ import {
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -145,9 +145,7 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
console.log("bookingbooking",booking)
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
console.log("amountminor",amountMinor)
const amountMinor = Math.round(Number(booking.totalAmount));
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -159,8 +157,8 @@ export class PaymentService {
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);

View File

@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// A train can be rescheduled (with or without bookings) at any time UNLESS it
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
if (schedule.status === TrainScheduleStatus.Dispatched) {
throw new BadRequestException('Cannot reschedule a dispatched train');
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
}
if (schedule.status === TrainScheduleStatus.Arrived) {
throw new BadRequestException('Cannot reschedule a train that has already arrived');
}
if (schedule.status === TrainScheduleStatus.Cancelled) {
throw new BadRequestException('Cannot reschedule a cancelled train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
}
}
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
});
// A train can be rescheduled even with no bookings (e.g. moved for
// maintenance). assignBookingsToSchedule requires at least one booking, so
// only call it when something is actually being (re)assigned — the new
// departure date above is the meaningful change for an empty train. The
// empty-train branch returns the same schedule-detail shape as the assign
// path so callers get a consistent response.
const assignResult = dto.finalBookingIds.length
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
})
: {
...(await this.trainSchedulingService.getContainerTrainScheduleById(
scheduleId,
)),
warnings: [] as string[],
deferredBookings: [] as unknown[],
};
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,

View File

@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
};
}
/**
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
* key for day-level booking pools — it must match the day the portal calendar
* renders, so always derive day keys through this (never `toISOString().slice`).
*/
export function eatDay(date: Date): string {
const { year, month, day } = eatParts(date);
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,

View File

@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
findBatchPoolByRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
getBookableSchedules: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
secured: jest.Mock;
expired: jest.Mock;
unplaced: jest.Mock;
};
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
// WagonType.find() / global-rules find() fall back to defaults when empty.
find: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
};
notifier = {
payNow: jest.fn(),
secured: jest.fn(),
expired: jest.fn(),
unplaced: jest.fn(),
};
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
);
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const day = '2026-06-20';
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
const trainA = 'train-a';
const trainB = 'train-b';
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
const commercial = (id: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
// Two OPEN trains on the same route + day, train A earlier than train B.
trainSchedulingService.getBookableSchedules.mockResolvedValue([
{
id: trainA,
scheduleDate: '2026-06-20T06:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
{
id: trainB,
scheduleDate: '2026-06-20T09:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
Promise.resolve({
id,
maxWagons: 1,
bookingWindowStatus: 'OPEN',
trainSetId: `set-${id}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
}),
);
});
it('spills overflow to the next train by priority, then reports unplaced', async () => {
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
commercial('hi', 30),
commercial('mid', 20),
commercial('lo', 10),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
originYardId,
destinationYardId,
day,
);
// Both trains were processed.
expect(touched).toEqual([trainA, trainB]);
// Highest priority reserved on train A, next on train B (commercial → reserve).
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedOn).toEqual(['hi', 'mid']);
// The third booking fits no train and is reported unplaced (and only it).
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
});
it('reserves the chosen train id on each commercial booking', async () => {
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'hi',
expect.objectContaining({
trainScheduleId: trainA,
status: 'SELECTED_FOR_BATCH',
}),
);
});
});
});

View File

@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { groupBookingsIntoBoardWindows } from './batch-window.util';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -42,6 +42,14 @@ interface Capacity {
lengthMeters: number;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
destinationYardId: string;
/** EAT calendar day, `yyyy-MM-dd`. */
day: string;
}
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
for (const s of open) {
const groups = await this.openRouteDayGroups();
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
this.logger.warn(
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
const reserved = await this.dataSource
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
/**
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
* (contract sign, payment). Day-level pooling distributes across all of that
* day's trains, so a single schedule id maps to its whole route-day group.
*/
enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
this.logger.error(
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
),
);
}
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return;
await this.processRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
});
}
/**
* Day-level pipeline: distribute the (route, day) pool across all its trains,
* then settle / reconcile / assign wagons per schedule (those steps stay
* schedule-scoped — only the fill is day-level).
*/
async processRouteDay(group: RouteDayGroup): Promise<void> {
const scheduleIds = await this.fillRouteDay(
group.originYardId,
group.destinationYardId,
group.day,
);
for (const scheduleId of scheduleIds) {
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
if (!s.scheduledDepartureDate) continue;
const day = eatDay(s.scheduledDepartureDate);
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
if (!groups.has(key)) {
groups.set(key, {
originYardId: s.originStationId,
destinationYardId: s.destinationStationId,
day,
});
}
}
return [...groups.values()];
}
private groupLabel(group: RouteDayGroup): string {
return `${group.originYardId}${group.destinationYardId} on ${group.day}`;
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
this.logger.error(
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
}
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
} else {
await this.reserve(booking);
await this.reserve(booking, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/**
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
* priority, filling each train (earliest departure first) until it's full and
* spilling overflow to the next. Government bookings that fit no train preempt
* lower-priority commercial; bookings that fit no train at all stay pending and
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
* (or that had remaining pool work) so the caller can settle them per-schedule.
*/
async fillRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<string[]> {
// The day's OPEN bookable schedules on this exact corridor, earliest first.
const bookable = await this.trainSchedulingService.getBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
if (scheduleIds.length === 0) return [];
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
originYardId,
destinationYardId,
day,
);
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
// First train (earliest departure) that fits this booking as-is.
let target = trains.find((t) => this.fits(need, t.budget));
if (!target && booking.isGovernment) {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
if (this.fits(need, t.budget)) {
target = t;
break;
}
}
}
if (!target) {
// Fits no train this day — stays in the pool, retried next batch.
this.notifier.unplaced(booking, day);
continue;
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
} else {
await this.reserve(booking, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
return trains.map((t) => t.id);
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
/** Reserve capacity for a commercial booking and open its pay window. */
private async reserve(booking: Booking): Promise<void> {
/**
* Reserve capacity for a commercial booking on a specific train and open its
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
* which is all keyed off `booking.trainScheduleId`, can find the train — with
* day-level pooling the booking arrives here with `trainScheduleId` still null,
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
await this.notifier.payNow(booking, deadline);
}
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/** Expire an unpaid reservation and free its capacity. */
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
this.notifier.expired(booking);
}

View File

@@ -67,6 +67,17 @@ export class BookingNotifierService {
);
}
/**
* Staff-facing warning when a pooled booking fits no train on its chosen day.
* It stays pending and is retried next batch; staff can add capacity or pin it
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
*/
unplaced(b: Booking, day: string): void {
this.logger.warn(
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
);
}
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class AvailableDaysQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
}

View File

@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
);
}
@Get("available-days")
// No staff guard: customers hit this while creating a booking to find which
// DAYS have a departure on their route. Day-level pooling — no capacity is
// returned, only the list of bookable days.
@ApiOperation({
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
})
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
return this.trainSchedulingService.getAvailableDays(
query.originYardId,
query.destinationYardId,
);
}
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: "List eligible container bookings" })

View File

@@ -87,6 +87,7 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
) {}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
// resolving the schedule's route + day and filtering on the day instead.
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
if (query.trainScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
if (schedule?.scheduledDepartureDate) {
day = eatDay(schedule.scheduledDepartureDate);
originStationId = originStationId ?? schedule.originStationId;
destinationStationId = destinationStationId ?? schedule.destinationStationId;
}
}
const bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId: query.originStationId,
destinationStationId: query.destinationStationId,
originStationId,
destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
@@ -1965,6 +2006,33 @@ export class TrainSchedulingService {
return filteredSchedules;
}
/**
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
* departure on the route. Customers pick a DAY (not a train) — so this returns
* only the day strings, no capacity, counts or train info.
*/
async getAvailableDays(
originYardId?: string,
destinationYardId?: string,
): Promise<{ days: string[] }> {
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
const days = new Set<string>();
for (const s of schedules) {
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
}
return { days: [...days].sort() };
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
return days.includes(day);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {

View File

@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
async run() {
await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
// Demo freight data (wagons + approval rules) disabled — keep only the
// 4 staff users. The seeders are retained for easy re-enabling; flip
// SEED_DEMO_FREIGHT_DATA=true to run them.
if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
}
await this.seedStaffUsers(manager);
});
}

View File

@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
@@ -552,6 +553,8 @@ const App = () => {
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route

View File

@@ -226,7 +226,7 @@ const FreightSidebar = ({
return (
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>

View File

@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>

View File

@@ -132,6 +132,29 @@ export const useBookableSchedules = (
enabled: Boolean(originYardId && destinationYardId),
});
/**
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
* day (not a train) when creating a booking; the engine assigns the train.
*/
export const useAvailableDays = (
originYardId?: string | null,
destinationYardId?: string | null,
) =>
useQuery({
queryKey: [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days",
originYardId ?? "",
destinationYardId ?? "",
],
queryFn: () =>
trainSchedulingService.getAvailableDays(
originYardId ?? undefined,
destinationYardId ?? undefined,
),
enabled: Boolean(originYardId && destinationYardId),
});
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),

View File

@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
originYardId,
destinationYardId,
);
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
const dayOptions = (availableDays ?? []).map((day) => ({
value: day,
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
}));
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
const hasAvailableDays = (availableDays ?? []).length > 0;
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
const effectiveDepartureIso = selectedSchedule
? new Date(selectedSchedule.scheduleDate).toISOString()
: scheduledDate
? new Date(scheduledDate).toISOString()
: "";
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
const effectiveDepartureIso = scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toISOString()
: "";
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
// Reset the day when the route changes — available days depend on the route.
useEffect(() => {
setTrainScheduleId(null);
setScheduledDay(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
// the train afterwards (same flow as the customer portal).
const departureSatisfied = Boolean(scheduledDay);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
// Day-level pool: no trainScheduleId — the engine assigns the train.
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
value={destinationYardId}
onChange={(v) => {
setDestinationYardId(v);
setTrainScheduleId(null);
setScheduledDay(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
/>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
</Text>
) : null}
<Select
label="Shipment day"
placeholder={
originYardId && destinationYardId
? "Select a day with a departure"
: "Pick origin & destination first"
}
data={dayOptions}
value={scheduledDay}
onChange={setScheduledDay}
searchable
disabled={!originYardId || !destinationYardId || daysLoading}
nothingFoundMessage={
hasAvailableDays ? "No match" : "No departures on this route"
}
description="Pick a day with a departure. The batch engine assigns the train by priority."
/>
<Group grow align="flex-end">
<Select
label="Service type"
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
<Group grow align="flex-start">
{selectedSchedule ? (
<TextInput
label="Departure"
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
readOnly
description="Taken from the selected train schedule"
/>
) : (
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
)}
<TextInput
label="Shipment day"
value={
scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: ""
}
placeholder="Pick a day in the Route section"
readOnly
description="The engine assigns the train on this day"
/>
<Select
label="Payment currency"
data={[

View File

@@ -12,9 +12,11 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { Badge as MantineBadge } from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
LayoutGrid,
Loader2,
RotateCcw,
Search,
@@ -24,6 +26,7 @@ import {
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import "@/components/overview/overview.css";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
@@ -39,11 +42,16 @@ import {
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined },
{ key: "success", label: "Success", statuses: "success" },
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
{ key: "refunded", label: "Refunded", statuses: "refunded" },
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
{
key: "processing",
label: "Processing",
statuses: "processing,action-required",
icon: Loader2,
},
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const tabCounts: Record<StatusTabKey, number | undefined> = {
all:
summary === undefined
? undefined
: (summary.success ?? 0) +
(summary.processing ?? 0) +
(summary.failed ?? 0) +
(summary.refunded ?? 0),
success: summary?.success,
processing: summary?.processing,
failed: summary?.failed,
refunded: summary?.refunded,
};
const columns: ColumnDef<PaymentRow>[] = [
{
id: "order",
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{STATUS_TABS.map((t) => (
<Tabs.Tab key={t.key} value={t.key}>
{t.label}
</Tabs.Tab>
))}
{STATUS_TABS.map((t) => {
const isActive = statusTab === t.key;
const count = tabCounts[t.key];
const Icon = t.icon;
return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>

View File

@@ -0,0 +1,512 @@
import { useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import {
Badge,
Box,
Breadcrumbs,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Boxes,
ChevronRight,
FileText,
Home,
Layers,
Package,
Pencil,
Plus,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
getRuleEngineResource,
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import {
useRuleEngineList,
useRuleEngineMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
const CARGO_SLUG = "cargo-types";
const BASE_PATH = "/dashboard/configuration/cargo-types";
interface CargoNode extends RuleEngineRecord {
cargoTypeName?: string;
code?: string;
parentGroupId?: string | null;
showFreeTextBox?: boolean;
requiresDirectorApproval?: boolean;
isActive?: boolean;
displayOrder?: number;
}
const str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
/** Create/edit form fields. Parent is set from the current page, never picked. */
const FORM_FIELDS: FormFieldDef[] = [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
const CargoTypesPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const { id: currentId } = useParams<{ id: string }>();
const config = getRuleEngineResource(CARGO_SLUG);
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
// One fetch of the whole (small) set; the tree, ancestry and each level are
// derived client-side so drilling between levels is instant.
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
page: 1,
pageSize: 500,
sortBy: "displayOrder",
sortOrder: "ASC",
});
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
const all = (data?.data ?? []) as CargoNode[];
const { byId, childrenOf } = useMemo(() => {
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
const childrenOf = new Map<string, CargoNode[]>();
for (const node of all) {
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
const key = parentId || "__root__";
const list = childrenOf.get(key) ?? [];
list.push(node);
childrenOf.set(key, list);
}
for (const list of childrenOf.values()) {
list.sort(
(a, b) =>
orderOf(a) - orderOf(b) ||
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
);
}
return { byId, childrenOf };
}, [all]);
// Current node (null at root) and its ancestor chain for the breadcrumb.
const current = currentId ? byId.get(currentId) ?? null : null;
const ancestors = useMemo(() => {
const chain: CargoNode[] = [];
let node = current;
const seen = new Set<string>();
while (node && !seen.has(node.id)) {
chain.unshift(node);
seen.add(node.id);
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
}
return chain;
}, [current, byId]);
const levelKey = current ? current.id : "__root__";
const levelNodes = childrenOf.get(levelKey) ?? [];
const term = search.trim().toLowerCase();
const matches = (n: CargoNode) =>
!term ||
str(n.cargoTypeName).toLowerCase().includes(term) ||
str(n.code).toLowerCase().includes(term);
const visibleNodes = useMemo(
() => (term ? levelNodes.filter(matches) : levelNodes),
[levelNodes, term],
);
if (!config) return <Navigate to="/dashboard/overview" replace />;
if (!canView) return <Navigate to="/dashboard/overview" replace />;
// A bad/stale :id (after data loads) → fall back to the root list.
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
const atRoot = !current;
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
const handleSubmit = (values: Record<string, unknown>) => {
const payload: Record<string, unknown> = { ...values };
// Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id;
}
const done = () => setFormMode(null);
if (formMode?.kind === "edit") {
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
} else {
create.mutate(payload, { onSuccess: done });
}
};
const addLabel = atRoot ? "Add category" : "Add cargo type";
return (
<Stack gap="lg">
{/* ── Header ─────────────────────────────────────────────── */}
<Card
p="lg"
radius="lg"
withBorder
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
>
{/* Breadcrumb */}
<Breadcrumbs
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
mb="md"
>
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
<Group gap={5} wrap="nowrap">
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
Cargo Types
</Text>
</Group>
</UnstyledButton>
{ancestors.map((node, i) => {
const isLast = i === ancestors.length - 1;
return (
<UnstyledButton
key={node.id}
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
style={{ cursor: isLast ? "default" : "pointer" }}
>
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
{str(node.cargoTypeName) || "Untitled"}
</Text>
</UnstyledButton>
);
})}
</Breadcrumbs>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={48} radius="md" variant="light" color="teal">
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={22} c="dark.8" truncate>
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
</Text>
{!atRoot && current?.code ? (
<Badge variant="default" radius="sm">
{str(current.code)}
</Badge>
) : null}
{!atRoot && current?.isActive === false ? (
<Badge variant="light" color="gray" radius="sm">
Inactive
</Badge>
) : null}
</Group>
<Text fz={13} c="dimmed" mt={2}>
{atRoot
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
</Text>
</Box>
</Group>
<Group gap="sm" wrap="nowrap">
<TextInput
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search this level…"
leftSection={<Search size={16} />}
w={240}
/>
{canManage && (
<Button
color="teal"
leftSection={<Plus size={16} />}
onClick={() => setFormMode({ kind: "create" })}
>
{addLabel}
</Button>
)}
</Group>
</Group>
</Card>
{/* ── Level list ─────────────────────────────────────────── */}
<Card
p={0}
radius="lg"
withBorder
style={{
background: "white",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
overflow: "hidden",
}}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader color="teal" />
</Group>
) : isError ? (
<Text p="xl" c="red" ta="center">
Failed to load cargo types.
</Text>
) : visibleNodes.length === 0 ? (
<Stack align="center" gap="sm" py={56}>
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
<Package size={26} />
</ThemeIcon>
<Text fw={600} c="dark.6">
{term
? "Nothing matches your search"
: atRoot
? "No cargo categories yet"
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
</Text>
{!term && canManage && (
<Button
variant="light"
color="teal"
leftSection={<Plus size={16} />}
onClick={() => setFormMode({ kind: "create" })}
>
{atRoot ? "Add your first category" : "Add the first cargo type"}
</Button>
)}
</Stack>
) : (
<Stack gap={0}>
{visibleNodes.map((node, i) => (
<CargoRow
key={node.id}
node={node}
childCount={(childrenOf.get(node.id) ?? []).length}
topBorder={i > 0}
canManage={canManage}
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
onEdit={() => setFormMode({ kind: "edit", record: node })}
onDelete={() => setDeleteTarget(node)}
/>
))}
</Stack>
)}
</Card>
{/* ── Create / edit dialog ───────────────────────────────── */}
<RuleEngineFormDialog
open={Boolean(formMode)}
onOpenChange={(open) => {
if (!open) setFormMode(null);
}}
title={
formMode?.kind === "edit"
? `Edit ${str(formMode.record.cargoTypeName)}`
: atRoot
? "Add category"
: `Add cargo under “${str(current?.cargoTypeName)}`
}
description={
formMode?.kind === "edit"
? "Update this cargo type."
: atRoot
? "Create a top-level cargo category."
: "Create a cargo type inside this category. It's attached here automatically."
}
fields={FORM_FIELDS}
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
isSubmitting={create.isPending || update.isPending}
onSubmit={handleSubmit}
/>
{/* ── Delete confirm ─────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Delete cargo type?"
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
<>
<Text span fw={600}>
{str(deleteTarget?.cargoTypeName)}
</Text>{" "}
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
leaves them without a category. Continue?
</>
) : (
<>
This will delete{" "}
<Text span fw={600}>
{str(deleteTarget?.cargoTypeName)}
</Text>
.
</>
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={remove.isPending}
onClick={() => {
if (!deleteTarget) return;
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
// ── A single cargo row — drills into its own page on click ──────────────────
interface CargoRowProps {
node: CargoNode;
childCount: number;
topBorder: boolean;
canManage: boolean;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
}
function CargoRow({
node,
childCount,
topBorder,
canManage,
onOpen,
onEdit,
onDelete,
}: CargoRowProps) {
const inactive = node.isActive === false;
const hasChildren = childCount > 0;
return (
<Group
justify="space-between"
wrap="nowrap"
px="lg"
py="md"
style={{
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
transition: "background 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
}}
>
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={650} fz={15} c="dark.8" truncate>
{str(node.cargoTypeName) || "Untitled"}
</Text>
{node.code ? (
<Badge size="xs" variant="default" radius="sm">
{str(node.code)}
</Badge>
) : null}
{node.requiresDirectorApproval ? (
<Tooltip label="Requires director approval" withArrow>
<Badge
size="xs"
variant="light"
color="orange"
radius="sm"
leftSection={<ShieldCheck size={11} />}
>
Approval
</Badge>
</Tooltip>
) : null}
{node.showFreeTextBox ? (
<Tooltip label="Shows a free-text box on booking" withArrow>
<Badge
size="xs"
variant="light"
color="blue"
radius="sm"
leftSection={<FileText size={11} />}
>
Free text
</Badge>
</Tooltip>
) : null}
{inactive ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Inactive
</Badge>
) : null}
</Group>
<Text fz={12.5} c="dimmed" mt={2}>
{hasChildren
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
: "No cargo types inside yet — open to add"}
</Text>
</Box>
</Group>
</UnstyledButton>
<Group gap={4} wrap="nowrap">
{canManage && (
<>
<Tooltip label="Edit" withArrow>
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
<Pencil size={15} />
</Button>
</Tooltip>
<Tooltip label="Delete" withArrow>
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
<Trash2 size={15} />
</Button>
</Tooltip>
</>
)}
<Tooltip label="Open" withArrow>
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
<ChevronRight size={18} />
</Button>
</Tooltip>
</Group>
</Group>
);
}
export default CargoTypesPage;

View File

@@ -109,6 +109,21 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
* a day; the batch engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
originYardId?: string,
destinationYardId?: string,
): Promise<string[]> => {
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: { originYardId, destinationYardId } },
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -144,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { user, company } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
return (
<Routes>
@@ -158,6 +161,9 @@ const App = () => {
path="/booking/check-status/:orderId"
element={<CheckPaymentPage />}
/>
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />
{/* Auth pages — inaccessible once logged in */}
<Route element={<RedirectIfAuthed />}>
@@ -185,6 +191,7 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
>
<Outlet />
</AppLayout>

View File

@@ -47,9 +47,19 @@ export interface AppLayoutProps {
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
children: ReactNode;
}
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
function getInitials(name: string): string {
return name
.split(" ")
@@ -106,6 +116,7 @@ export function AppLayout({
enableThemeToggle = false,
userName = "User",
userEmail,
companyProfiles = [],
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -306,6 +317,34 @@ export function AppLayout({
</Text>
)}
</Box>
{companyProfiles.length > 0 && (
<>
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>
)}
<Divider />
<Menu.Item
leftSection={<User size={15} />}

View File

@@ -0,0 +1,151 @@
import type { ReactNode } from "react";
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg";
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" 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>
);
export interface AuthShellProps {
children: ReactNode;
/** Tagline shown in the highlighted card over the left image panel. */
tagline?: string;
taglineBody?: string;
}
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
<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">
{tagline ?? "Empower Your Freight Operations"}
</span>
</div>
<p className="text-sm leading-relaxed text-white/85">
{taglineBody ??
"Sign in to manage shipments, 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 &amp; 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 &amp; Support
</a>
</div>
</div>
);
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
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 tagline={tagline} taglineBody={taglineBody} />
<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">
{children}
</div>
</div>
</div>
<FormFooter />
</div>
</div>
</div>
</>
);
}

View File

@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
COMPANY_PROFILES: "/api/companies/company-profiles",
DASHBOARD: "/api/companies/dashboard",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
@@ -100,6 +101,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
},
PAYMENTS: {

View File

@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : (
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack>
</Group>
</Box>

View File

@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
{formatCurrency(totalOutstanding || 0, "ETB")}
</Text>
<Group
justify="space-between"

View File

@@ -1,17 +1,113 @@
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
</div>
</div>
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
@@ -22,163 +118,293 @@ export default function ProfilePage() {
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<div className="px-4 py-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{profile.companyName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{profile.companyName}
</p>
</div>
</div>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Separator />
<Divider mb="lg" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Power of Attorney */}
{profile.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</div>
</Card>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -1,39 +1,51 @@
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { ArrowRight, Mail, Phone } from "lucide-react";
import { useState } from "react";
import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/edr-logo.png";
type LoginMethod = "email" | "phone";
const loginMethods: Array<{
value: LoginMethod;
label: string;
icon: typeof Mail;
placeholder: string;
}> = [
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
];
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const currentMethod = loginMethods.find((item) => item.value === method)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)
?.from?.pathname;
const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname;
navigate(from ?? "/portal", { replace: true });
} else {
setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
};
return (
<AuthLayout
left={{
badge: "Welcome Back",
title: "Sign in to your freight operations account",
description:
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
>
<Stack gap="xs" mb="lg">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
<AuthShell>
<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>
<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">
Welcome back
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Enter your credentials to access your portal
</Text>
</Box>
</Stack>
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your credentials to access your freight portal.
</p>
</div>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<SegmentedControl
value={method}
onChange={(v) => setMethod(v as LoginMethod)}
fullWidth
radius="md"
data={[
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Mail size={15} />
<Text size="sm">Email</Text>
</Group>
),
value: "email",
},
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Phone size={15} />
<Text size="sm">Phone</Text>
</Group>
),
value: "phone",
},
]}
/>
{method === "email" ? (
<TextInput
label="Email Address"
placeholder="name@company.com"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setPhoneNumber(e.target.value),
}}
/>
)}
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={500} c="edr-text">
Password
</Text>
<Button
variant="transparent"
size="xs"
c="edr-green.6"
p={0}
h="auto"
fz={12}
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={method}
onChange={(event) => setMethod(event.target.value as LoginMethod)}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
Forgot password?
</Button>
</Group>
<PasswordInput
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
{loginMethods.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span>
</label>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
</Box>
</div>
{error && (
<Alert color="red" variant="light" radius="md">
<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">
{error}
</Alert>
)}
</div>
) : null}
<Button
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Sign In
</Button>
<button type="submit" disabled={loading} className={primaryButtonClass}>
{loading ? "Signing in..." : "Sign In"}
</button>
<Text size="sm" c="edr-muted" ta="center">
Don't have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<p className="text-center text-sm text-gray-500">
Don&apos;t have an account?{" "}
<button
type="button"
onClick={() => navigate("/signup")}
className="font-semibold text-primary hover:underline"
>
Create an account
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</form>
</AuthLayout>
</AuthShell>
);
}

View File

@@ -1,7 +1,6 @@
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/edr-logo.png";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
@@ -20,11 +20,22 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const ETHIOPIA_COUNTRY_CODE = "+251";
const isValidEthiopianMobile = (value: string) => {
const digits = value.replace(/\D/g, "");
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
return /^9\d{8}$/.test(normalized);
};
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
countryCode: z.string().min(1, "Country code is required"),
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z
.string()
.min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
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() }),
@@ -44,11 +55,16 @@ const userSchema = z
type FormData = z.infer<typeof userSchema>;
const errorText = (msg?: string) =>
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const {
register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
countryCode: "+251",
countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
const digits = data.phone.replace(/\D/g, "");
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
return (
<AuthLayout
left={{
badge: "Smart Freight Operations",
title: "Create your freight operations account",
description:
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
<AuthShell
tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Create Account
</Text>
<Text fz={15} c="edr-muted" mt={4}>
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<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>
<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.
</Text>
</Box>
</Stack>
</p>
</div>
{error && (
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</Alert>
)}
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
First name <span className="text-red-500">*</span>
</label>
<input
placeholder="John"
disabled={loading}
className={fieldClass}
{...register("firstName.en")}
/>
{errorText(errors.firstName?.en?.message)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Last name <span className="text-red-500">*</span>
</label>
<input
placeholder="Doe"
disabled={loading}
className={fieldClass}
{...register("lastName.en")}
/>
{errorText(errors.lastName?.en?.message)}
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="John"
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
placeholder="john@example.com"
disabled={loading}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
className={fieldClass}
{...register("email")}
/>
<TextInput
label="Last Name"
placeholder="Doe"
disabled={loading}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
{errorText(errors.email?.message)}
</div>
<TextInput
label="Email Address"
placeholder="john@example.com"
type="email"
disabled={loading}
error={errors.email?.message}
{...register("email")}
/>
<div className="space-y-1.5">
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<input type="hidden" {...register("countryCode")} />
<div
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
}`}
>
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
{ETHIOPIA_COUNTRY_CODE}
</span>
<input
id="signup-phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
placeholder="0912345678"
maxLength={10}
disabled={loading}
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
{...register("phone", {
onChange: (event) => {
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
},
})}
/>
</div>
{errorText(errors.phone?.message)}
</div>
<PhoneInput
disabled={loading}
countryCode={{ ...register("countryCode") }}
phone={{ ...register("phone") }}
countryCodeError={errors.countryCode}
phoneError={errors.phone}
/>
<Box>
<PasswordInput
label="Password"
placeholder="Create a strong password"
disabled={loading}
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 && (
<Stack gap={4} mt={8}>
<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"}
placeholder="Create a strong password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("password")}
/>
<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>
{errorText(errors.password?.message)}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<Group key={req.label} gap={6} align="center" wrap="nowrap">
<ThemeIcon
size={16}
radius="xl"
variant={met ? "filled" : "light"}
color={met ? "edr-green" : "gray"}
<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 size={10} /> : <X size={10} />}
</ThemeIcon>
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
{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}
</Text>
</Group>
</span>
</div>
);
})}
</Stack>
)}
</Box>
</div>
) : null}
</div>
<PasswordInput
label="Confirm Password"
placeholder="Re-enter your password"
disabled={loading}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showConfirm ? "text" : "password"}
placeholder="Re-enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirm((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showConfirm ? "Hide password" : "Show password"}
>
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.confirmPassword?.message)}
</div>
<Button
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<button
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
>
Create Account
</Button>
{loading ? "Creating account..." : "Create Account"}
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
</button>
<Text size="sm" c="edr-muted" ta="center">
<p className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</form>
</AuthLayout>
</AuthShell>
);
}

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
const [priceChangeModal, setPriceChangeModal] =
useState<SubmitBookingResponse | null>(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeModal(result);
return;
}
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
mutations={[
uploadMutation,
submitMutation,
confirmSubmitMutation,
cancelMutation,
]}
/>
<StatusHero booking={booking}>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
return (
<DocRow
key={doc.key}
@@ -276,13 +312,19 @@ export function DraftBookingView({
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
isUploaded && !allowReplace ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
)}
<input
ref={(el) => {
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
{selected ? "Change" : "Add"}
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
</Button>
{selected && (
<ActionIcon
@@ -375,6 +417,72 @@ export function DraftBookingView({
}
/>
<Modal
opened={priceChangeModal !== null}
onClose={() => setPriceChangeModal(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeModal && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeModal.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeModal.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
<Stack gap={4}>
{priceChangeModal.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeModal(null)}
>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { CancelledBanner } from "./components/Notices";
import {
CancelledBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
// before approval, so it doesn't linger for the rest of the booking's life.
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
return (
<PageShell>
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
)}
{showPairedNotice && <ConsolidationPairedNotice />}
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
@@ -163,6 +180,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
}}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending}
error={
payMutation.isError

View File

@@ -1,5 +1,13 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Link2,
PencilLine,
StickyNote,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
export function NoticeBanner({
@@ -186,6 +194,90 @@ export function CancelledBanner({
);
}
/**
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
* waiting for another shipment to share the wagon. The price shown is this
* booking's own held amount — bookings and contracts are independent, so the
* partner's amount is never shown. Once a partner is found the backend moves
* the booking back to SUBMITTED and it continues the normal flow.
*/
export function ConsolidationWaitingBanner({
priceLabel,
}: {
priceLabel?: string;
}) {
return (
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap" miw={0}>
<div
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
>
<Link2 size={24} />
</div>
<Box miw={0}>
<span
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
style={{ backgroundColor: "#C77F12" }}
>
Waiting for a partner
</span>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
Your shipment is waiting to share a wagon
</Text>
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
Your cargo only fills part of a wagon, so were pairing it with
another shipment on the same route to share the space. As soon as a
matching shipment is found, your booking continues automatically
acceptance, approval and contract stay independent and yours alone.
</Text>
</Box>
</Group>
{priceLabel && (
<div
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
style={{ backgroundColor: "#fff" }}
>
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
Your price (held)
</Text>
<Text mt={3} fz="18px" fw={800} c="#10202F">
{priceLabel}
</Text>
</div>
)}
</Group>
</Paper>
);
}
/**
* A brief positive notice shown once a consolidation partner has been found and
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
* Reassures the customer the wait ended; the booking proceeds independently.
*/
export function ConsolidationPairedNotice() {
return (
<div
className="flex items-start gap-3 rounded-[14px] border p-4"
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
>
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<Text fz="13.5px" fw={800} c="#1B7A3D">
Consolidation partner found
</Text>
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
A matching shipment was found to share the wagon, so your booking is
back on track and now moving through review and approval as usual.
Nothing more is needed from you for now.
</Text>
</div>
</div>
);
}
export function MutationErrors({
mutations,
}: {

View File

@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval);
}, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
<SectionCard p={22}>
<SectionCard
p={22}
style={
remaining.expired
? undefined
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
}
>
<Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle>
<Group

View File

@@ -1,6 +1,6 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { Smartphone, type LucideIcon } from "lucide-react";
import { useState } from "react";
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Check, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { PaymentMethod } from "@/services/payments.service";
@@ -8,7 +8,10 @@ interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
icon: LucideIcon;
logo: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
label: "telebirr",
description: "Ethiopian mobile money",
icon: Smartphone,
description: "Ethiopian mobile money · ETB",
logo: "/assets/telebirr.jpeg",
currencies: ["ETB"],
accent: "#0A6F4D",
},
{
method: "WAAFI",
label: "Waafi",
description: "Djibouti mobile money",
icon: Smartphone,
description: "Djibouti mobile money · USD",
logo: "/assets/waafi.jpeg",
currencies: ["USD"],
accent: "#2E5B96",
},
];
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
*/
function providersForCurrency(currency?: string | null): ProviderOption[] {
const cur = currency?.trim().toUpperCase();
if (!cur) return PROVIDERS;
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
return matched.length > 0 ? matched : PROVIDERS;
}
function ProviderRow({
option,
selected,
@@ -36,55 +54,75 @@ function ProviderRow({
selected: boolean;
onSelect: () => void;
}) {
const Icon = option.icon;
return (
<Group
onClick={onSelect}
gap={12}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
gap={14}
wrap="nowrap"
align="center"
style={{
cursor: "pointer",
borderRadius: 12,
padding: "13px 14px",
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
backgroundColor: selected ? "#ECF6F1" : "#fff",
transition: "border-color .12s, background-color .12s",
borderRadius: 14,
padding: "14px 16px",
border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
backgroundColor: selected ? "#F6FBF8" : "#fff",
boxShadow: selected
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
: "none",
transition: "border-color .14s, box-shadow .14s, background-color .14s",
}}
>
<Box
style={{
width: 40,
height: 40,
width: 52,
height: 52,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
borderRadius: 12,
overflow: "hidden",
border: "1px solid #EEF2F6",
backgroundColor: "#fff",
}}
>
<Icon size={19} />
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
</Box>
<Box style={{ flex: 1 }}>
<Text fz="14px" fw={700} c="#10202F">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
{option.label}
</Text>
<Text fz="12.5px" c="#9AA8B5">
<Text fz="12.5px" c="#7A8794" truncate>
{option.description}
</Text>
</Box>
<Box
style={{
width: 18,
height: 18,
width: 22,
height: 22,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
backgroundColor: selected ? option.accent : "transparent",
transition: "all .14s",
}}
/>
>
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
</Box>
</Group>
);
}
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
opened,
onClose,
amountLabel,
currency,
onConfirm,
processing,
error,
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
onClose: () => void;
/** Human-readable total, e.g. "ETB 12,500". */
amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
processing?: boolean;
error?: string | null;
}) {
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
if (!providers.some((p) => p.method === method)) {
setMethod(providers[0].method);
}
}, [providers, method]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size={460}
title={
<Stack gap={2}>
<Text fw={800} fz="17px" c="#10202F">
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
radius={18}
size={480}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Stack gap={10}>
{PROVIDERS.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
{/* Header */}
<Box px={24} pt={24} pb={18}>
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
Complete your payment
</Text>
<Text mt={4} fz="13px" c="#7A8794">
Choose how you'd like to pay for this booking.
</Text>
{amountLabel && (
<Group
mt={16}
justify="space-between"
align="center"
px={16}
py={13}
style={{
borderRadius: 12,
background:
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Amount due
</Text>
<Text fz="20px" fw={800} c="#10202F">
{amountLabel}
</Text>
</Group>
)}
</Box>
{/* Provider options */}
<Box px={24} pb={4}>
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
Payment method
</Text>
<Stack gap={10}>
{providers.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
</Stack>
</Box>
{/* Footer */}
<Box px={24} pt={16} pb={22}>
{error && (
<Text fz="12.5px" c="#C0392B" fw={600}>
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
{error}
</Text>
)}
<Button
fullWidth
mt={6}
radius={10}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
<Text fz="11.5px" c="#9AA8B5" ta="center">
You'll be redirected to your provider to complete payment securely.
</Text>
</Stack>
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Button
variant="default"
radius={12}
onClick={onClose}
disabled={processing}
styles={{
root: { height: 48, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
</Group>
</Box>
</Modal>
);
}

View File

@@ -61,7 +61,6 @@ export function ScheduleCard({
: "Rail only";
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
@@ -80,7 +79,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
{ label: "Consolidation", value: consolidation },
]
: [
statusRow,
@@ -88,7 +86,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
{ label: "Consolidation", value: consolidation },
];
return (

View File

@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
];
return (

View File

@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, FileText, History } from "lucide-react";
import {
AlertTriangle,
Check,
FileText,
History,
MapPin,
MoveRight,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
const ACCENT = "#F2A516";
/** Origin → destination strip rendered above the progress tracker. */
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
const origin = yardLabel(booking.originYard);
const destination = yardLabel(booking.destinationYard);
return (
<Box
mb={22}
px={18}
py={14}
className="rounded-2xl"
style={{
background:
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<RouteEndpoint label="Origin" value={origin} />
<Box
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 34,
height: 34,
backgroundColor: "#fff",
border: `1px solid ${ACCENT}33`,
color: ACCENT,
}}
>
<MoveRight size={18} />
</Box>
<RouteEndpoint label="Destination" value={destination} alignRight />
</Group>
</Box>
);
}
function RouteEndpoint({
label,
value,
alignRight,
}: {
label: string;
value: string;
alignRight?: boolean;
}) {
return (
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
<Group
gap={5}
align="center"
wrap="nowrap"
justify={alignRight ? "flex-end" : "flex-start"}
>
<MapPin size={12} color={ACCENT} />
<Text
fz="10.5px"
fw={700}
c="#B07D14"
tt="uppercase"
className="tracking-[0.6px]"
>
{label}
</Text>
</Group>
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
{value}
</Text>
</Box>
);
}
export function StatusHero({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
{!negative && <RouteStrip booking={booking} />}
{children ?? (
<ProgressTracker
current={cfg.stage}

View File

@@ -70,11 +70,11 @@ export function EstimateCard({
mb={4}
style={{
borderRadius: 6,
backgroundColor: "#F1F4F7",
backgroundColor: "#FEF6E6",
padding: "3px 7px",
fontSize: 11,
fontWeight: 700,
color: "#6B7C8E",
color: "#B07D14",
}}
>
est.

View File

@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
Textarea,
TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
CountChip,
DocRow,
@@ -52,12 +53,33 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps";
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null,
const EDIT_SECTIONS = [
"service",
"route",
"cargo",
"schedule",
"documents",
"notes",
] as const;
type EditSection = (typeof EDIT_SECTIONS)[number];
function isEditSection(value: string | null): value is EditSection {
return EDIT_SECTIONS.includes(value as EditSection);
}
function yardIdFromBooking(
yard: Freight.IYard | null | undefined,
referenceData: Freight.BookingReferenceData,
): string {
return yard?.label ?? yard?.name ?? yard?.code ?? "";
if (yard?.id) return yard.id;
const label = yard?.label ?? "";
return (
referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
);
}
/** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +130,19 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
originYard: yardNameFromBooking(booking.originYard),
destinationYard: yardNameFromBooking(booking.destinationYard),
originYard: yardIdFromBooking(booking.originYard, referenceData),
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.name ?? "",
shippingLine: (booking as any).shippingLine?.id ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
paymentCurrency:
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -237,9 +264,20 @@ const DIRECTION_LABEL: Record<string, string> = {
export default function EditBookingPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const sectionParam = searchParams.get("section");
const activeSection: EditSection = isEditSection(sectionParam)
? sectionParam
: "service";
function setSection(section: EditSection) {
setSearchParams({ section });
window.scrollTo({ top: 0, behavior: "smooth" });
}
const bookingQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: id! },
@@ -269,18 +307,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
const result = await api.bookings.update.call({ id: id!, dto: payload });
// Upload any newly attached documents against the existing booking.
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
const newDocuments: BookingDocuments = {};
for (const [key, value] of Object.entries(documents)) {
if (value) newDocuments[key] = value;
}
return result;
return api.bookings.update.call({
id: id!,
dto: payload,
documents:
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +341,9 @@ export default function EditBookingPage() {
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.name === originYard);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find(
(y) => y.name === destinationYard,
(y) => y.id === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
@@ -314,7 +351,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
value: y.id,
label: y.name,
country: y.country,
}));
@@ -322,10 +359,16 @@ export default function EditBookingPage() {
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// Dedupe by name (the value the form keys on) so two lines sharing a name
// can't produce a duplicate Select option and crash Mantine.
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {
@@ -338,17 +381,8 @@ export default function EditBookingPage() {
};
const handleSubmit = form.handleSubmit((data) => {
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const cargoTypePath = data.cargoTypePath ?? [];
const cargoTypeId =
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
@@ -369,10 +403,16 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
const selectedSvc = referenceData?.service.find(
(s) => s.id === data.serviceTypeId,
);
const apiPayload: Partial<CreateBookingPayload> = {
scheduledDate: new Date().toISOString().slice(0, 10),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
// Day-level pool: the customer edits only the day; the engine assigns the
// train, so trainScheduleId is not sent.
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -380,8 +420,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection:
direction === "EXPORT"
? "EXPORT"
@@ -391,9 +431,8 @@ export default function EditBookingPage() {
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -419,7 +458,7 @@ export default function EditBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: findShippingLineId(data.shippingLine) }
? { shippingLineId: data.shippingLine }
: {}),
};
@@ -505,9 +544,34 @@ export default function EditBookingPage() {
</Alert>
)}
<Stack gap={36} mt="xl">
{/* ── Section 1: Service ── */}
<Stack gap="md">
{booking.status === "CHANGES_REQUESTED" && (
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
<Text size="sm" fw={600}>
Staff requested changes
</Text>
<Text size="sm" mt={4}>
Update the sections below and save. Then return to the booking page to
resubmit for review.
</Text>
</Alert>
)}
<Tabs
value={activeSection}
onChange={(value) => value && setSection(value as EditSection)}
mt="xl"
>
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
<Tabs.Tab value="service">Service</Tabs.Tab>
<Tabs.Tab value="route">Route</Tabs.Tab>
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
<Tabs.Tab value="documents">Documents</Tabs.Tab>
<Tabs.Tab value="notes">Notes</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="service">
<Stack gap="md">
<SectionHeading
title="Service"
description="Select the service combination and configure trucking options."
@@ -548,6 +612,8 @@ export default function EditBookingPage() {
/>
</SimpleGrid>
<PaymentCurrencyField control={form.control} />
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||
selectedService?.includesCustoms) && (
@@ -648,10 +714,9 @@ export default function EditBookingPage() {
</Paper>
)}
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 3: Route ── */}
<Tabs.Panel value="route">
<Stack gap="md">
<SectionHeading
title="Route"
@@ -746,10 +811,9 @@ export default function EditBookingPage() {
/>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 4: Cargo ── */}
<Tabs.Panel value="cargo">
<Box>
<Step5CargoDetails
form={form}
@@ -758,10 +822,13 @@ export default function EditBookingPage() {
isLoading={!referenceData}
/>
</Box>
</Tabs.Panel>
<Divider />
<Tabs.Panel value="schedule">
<StepScheduling form={form} referenceData={referenceData} />
</Tabs.Panel>
{/* ── Section 5: Documents ── */}
<Tabs.Panel value="documents">
<Stack gap="md">
<SectionHeading
title="Documents"
@@ -855,10 +922,9 @@ export default function EditBookingPage() {
</Box>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 6: Notes ── */}
<Tabs.Panel value="notes">
<Stack gap="md">
<SectionHeading
title="Notes"
@@ -878,7 +944,8 @@ export default function EditBookingPage() {
)}
/>
</Stack>
</Stack>
</Tabs.Panel>
</Tabs>
{/* ── Submit ── */}
<Group

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -8,14 +8,44 @@ import {
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -24,25 +54,86 @@ import {
usePagination,
} from "@edr/ui-common";
// ── Status badge ──────────────────────────────────────────────────────────────
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
};
const STATUS_FILTERS = [
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? {
bg: "#F1F4F7",
dot: "#94A3B8",
color: "#475569",
label: status.replace(/_/g, " "),
};
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
return (
<Group
gap={6}
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
backgroundColor: bg,
padding: "5px 11px",
}}
>
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.dot,
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
status,
id,
booking,
onNavigate,
}: {
status: string;
id: string;
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
<Button
@@ -89,57 +180,39 @@ function PrimaryAction({
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
onClick={go}
>
Continue
</Button>
);
}
if (status === "AWAITING_PAYMENT") {
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Pay
Review changes
</Button>
);
}
if (status === "IN_TRANSIT") {
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
</Button>
);
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
View
</Button>
);
}
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) {
return (
<Text
@@ -155,20 +228,164 @@ function ColHeader({ label }: { label: string }) {
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
const bookings = data?.items ?? [];
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const total = bookings.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[statuses, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
};
const allItems = data?.items ?? [];
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page.
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[b.reference, b.originYard?.label, b.destinationYard?.label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty =
!isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
@@ -178,8 +395,7 @@ export default function MyBookings() {
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
@@ -217,7 +433,7 @@ export default function MyBookings() {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
@@ -245,7 +461,10 @@ export default function MyBookings() {
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
@@ -267,24 +486,42 @@ export default function MyBookings() {
header: () => null,
cell: ({ row }) => {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
@@ -293,8 +530,6 @@ export default function MyBookings() {
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
@@ -305,81 +540,112 @@ export default function MyBookings() {
Bookings
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery.
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Group gap={12}>
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
Export
</Button>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New Booking
</Button>
</Group>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group
justify="flex-end"
gap={8}
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<ArrowUpDown size={14} />}
>
Sort
</Button>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<Filter size={14} />}
>
Filter
</Button>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by status"
/>
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* Empty state */}
{total === 0 && dataTableStatus === "success" ? (
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
No bookings yet
{query ? "No bookings match your search" : "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
You haven't made any booking requests yet. Create your first one to get started.
{query
? "Try a different reference or clear the search."
: "Create your first booking to get started."}
</Text>
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
{!query && (
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={paginatedData}
data={rows}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
@@ -391,6 +657,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
@@ -398,6 +666,20 @@ export default function MyBookings() {
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -1,7 +1,9 @@
import { api } from "@/services/api";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
const createMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
mode,
existingBookingId,
}: {
payload: CreateBookingPayload;
mode: PriceModalMode;
existingBookingId: string | null;
}) => {
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
let bookingId = existingBookingId;
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
if (bookingId) {
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
} else {
const booking = await api.bookings.create.call({ payload, documents });
bookingId = booking.id;
}
return booking;
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
return { bookingId, pricing, mode };
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
},
});
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
return api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeResult(result);
return;
}
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const confirmSubmitMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
const [pricingPhase, setPricingPhase] = useState<
"idle" | "generating" | "ready"
>("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null,
);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
const [priceChangeResult, setPriceChangeResult] =
useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -232,13 +231,9 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
@@ -265,7 +260,9 @@ export default function NewBookingPage() {
)!;
return {
scheduledDate: new Date().toISOString(),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -273,12 +270,13 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
paymentCurrency: "USD",
paymentCurrency: data.paymentCurrency,
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,
@@ -307,31 +305,61 @@ export default function NewBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: findShippingLineId(data.shippingLine) }
? { shippingLineId: data.shippingLine }
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
}
const handleDraftSubmit = form.handleSubmit((data) => {
const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "draft",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
const handleSubmitBooking = form.handleSubmit((data) => {
if (!hasAllRequiredDocuments(data.documents)) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
setStep(6);
return;
}
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "submit",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const isPricing =
persistAndPriceMutation.isPending || confirmMutation.isPending;
function closePriceModal() {
setPriceModalMode(null);
if (priceModalMode === "draft" && priceBookingId) {
navigate(`/bookings/${priceBookingId}`);
}
}
function handleDraftModalOk() {
setPriceModalMode(null);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}
return (
<Box
style={{
@@ -376,14 +404,14 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleDraftSubmit}
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
{createMutation.isError && (
{persistAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -391,29 +419,11 @@ export default function NewBookingPage() {
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
Failed to save booking or generate price
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
{persistAndPriceMutation.error instanceof Error
? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
@@ -450,17 +460,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "draft"
}
submitPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/>
)}
</Box>
@@ -501,51 +510,151 @@ export default function NewBookingPage() {
>
Continue
</Button>
) : pricingPhase === "idle" ? (
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending
? "Saving Draft..."
: "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : (
<Send size={16} />
)
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending
? "Generating price…"
: "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button type="button" color="edr-green" radius="md" loading>
Generating price estimate
) : (
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={handleSubmitBooking}
loading={isPricing}
>
Submit
</Button>
) : null}
)}
</Group>
</Box>
</form>
<Modal
opened={priceModalMode !== null && pricingData !== null}
onClose={closePriceModal}
title={
<Text fw={700}>
{priceModalMode === "submit"
? "Confirm booking submission"
: "Draft saved — price estimate"}
</Text>
}
radius="lg"
centered
size="md"
>
{pricingData && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
: "Your booking has been saved as a draft. Here is the estimated price."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group justify="flex-end" gap="sm" mt="md">
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
radius="md"
onClick={closePriceModal}
disabled={confirmMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
>
Confirm & submit
</Button>
</>
) : (
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
OK
</Button>
)}
</Group>
</Stack>
)}
</Modal>
<Modal
opened={priceChangeResult !== null}
onClose={() => setPriceChangeResult(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeResult && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeResult.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeResult.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeResult.totalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeResult(null)}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
const GREEN = "var(--mantine-color-edr-green-5)";
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
return (
<div className="flex items-center">
{STEPS.map((item, index) => (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
transition: "all 0.2s",
...(step > item.id
? {
backgroundColor: "var(--mantine-color-edr-green-5)",
color: "#fff",
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
<div className="flex items-start">
{STEPS.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
transition: "all 0.2s",
...(done
? {
border: "2.5px solid var(--mantine-color-edr-green-5)",
color: "var(--mantine-color-edr-green-7)",
backgroundColor: "#fff",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
color: "#fff",
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
}
: {
backgroundColor: "#fff",
color: "var(--mantine-color-edr-muted-0)",
border: "2px solid var(--mantine-color-edr-border-0)",
}),
}}
>
{step > item.id ? (
<Check style={{ width: 13, height: 13 }} />
) : (
item.id
)}
: active
? {
border: `2.5px solid ${GREEN}`,
color: GREEN_DEEP,
backgroundColor: "#fff",
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
}
: {
backgroundColor: "#fff",
color: MUTED,
border: `2px solid ${BORDER}`,
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
</div>
<span
style={{
fontSize: 11,
fontWeight: active ? 700 : 500,
textAlign: "center",
lineHeight: 1.2,
maxWidth: 72,
display: "none",
transition: "color 0.2s",
color: step >= item.id ? INK : MUTED,
}}
className="md:!block"
>
{item.short}
</span>
</div>
<span
style={{
fontSize: 10,
fontWeight: 500,
display: "none",
transition: "color 0.2s",
color:
step >= item.id
? "var(--mantine-color-edr-text-0)"
: "var(--mantine-color-edr-muted-0)",
}}
className="lg:!block"
>
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 2,
borderRadius: 999,
margin: "0 6px",
marginBottom: 14,
transition: "background-color 0.3s",
backgroundColor:
step > item.id
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-0)",
}}
/>
)}
</Fragment>
))}
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 3,
borderRadius: 999,
margin: "16px 8px 0",
transition: "background 0.3s",
background: done
? "linear-gradient(90deg, #0A8A5F, #12B981)"
: BORDER,
}}
/>
)}
</Fragment>
);
})}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Box, Text } from "@mantine/core";
import { Banknote, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
type BookingFormInputValues,
type BookingFormValues,
type PaymentCurrency,
} from "./schema";
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
PaymentCurrency,
{ icon: typeof DollarSign; bg: string; color: string }
> = {
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
};
export function PaymentCurrencyField({
control,
}: {
control: Control<BookingFormInputValues, any, BookingFormValues>;
}) {
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Choose the currency for your freight quote and invoices.
</Text>
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
return (
<OptionCard
key={option.value}
selected={field.value === option.value}
onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />}
iconBg={CURRENCY_ICONS[option.value].bg}
iconColor={CURRENCY_ICONS[option.value].color}
title={option.label}
description={option.description}
/>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
</Box>
);
}

View File

@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
"Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<string, File | File[] | null>;
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
export const PAYMENT_CURRENCY_OPTIONS: Array<{
value: PaymentCurrency;
label: string;
description: string;
}> = [
{
value: "USD",
label: "USD",
description: "US Dollar — international pricing and invoicing.",
},
{
value: "ETB",
label: "ETB",
description: "Ethiopian Birr — local pricing and invoicing.",
},
];
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -94,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -118,7 +138,10 @@ export const bookingFormSchema = z
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
consolidationEnabled: z.boolean(),
// Consolidation is system-managed, not a customer choice. The backend only
// consolidates partial-wagon bookings, so this is always allowed; the
// customer neither sees nor toggles it.
consolidationEnabled: z.boolean().default(true),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
})
@@ -202,6 +225,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",
serviceTypeId: "",
paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -216,14 +240,13 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
consolidationEnabled: true,
documents: {},
notes: "",
};
@@ -232,6 +255,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
@@ -244,14 +268,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"isRefrigerated",
"shippingLine",
],
4: [
"cargoType",
"cargoWeight",
"cargoTypePath",
"containers",
"consolidationEnabled",
],
5: ["scheduledDate", "trainScheduleId"],
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};
@@ -267,22 +285,33 @@ export interface WagonConfig {
type: "20ft" | "40ft";
}
/**
* Derive the trade direction from the origin/destination yard countries.
*
* Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
* sends always matches what the API re-derives (the API rejects mismatches):
* - origin in Djibouti → IMPORT
* - destination in Djibouti (origin not) → EXPORT
* - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
*
* Returns null only while a yard is still unselected, so the UI can wait.
*/
export function getRouteDirection(
origin: Freight.BookingReferenceYard | null | undefined,
dest: Freight.BookingReferenceYard | null | undefined,
): Freight.ScheduleTradeDirection | null {
if (!origin || !dest) return null;
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
return "DOMESTIC";
}
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "EXPORT";
}
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
const originCountry = origin.country?.trim();
const destCountry = dest.country?.trim();
if (originCountry === "Djibouti") {
return "IMPORT";
}
return null;
if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
return "EXPORT";
}
return "DOMESTIC";
}
export function calcWagons(containers: ContainerConfig[]) {

View File

@@ -1,48 +1,167 @@
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
import {
Alert,
Box,
Combobox,
Group,
Input,
InputBase,
Paper,
Select,
Text,
Title,
useCombobox,
} from "@mantine/core";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
Loader,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
// Brand tokens (kept local so the form reads consistently with the booking
// detail page and the scheduling step).
const INK = "#10202F";
const MUTED = "#6B7C8E";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const BORDER = "#E6ECF2";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
<Text size="xs" c="red" mt={6}>
{error.message}
</Text>
);
}
/**
* Premium selectable option card with an icon tile, title, and description.
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
* `children` (legacy callers still work).
*/
export function OptionCard({
selected,
onClick,
disabled,
icon,
iconBg = "#ECF6F1",
iconColor = GREEN_DARK,
title,
description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
children: ReactNode;
icon?: ReactNode;
iconBg?: string;
iconColor?: string;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
style={{
position: "relative",
width: "100%",
textAlign: "left",
borderRadius: 16,
padding: 18,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 150ms ease",
border: `1.5px solid ${
disabled ? BORDER : selected ? GREEN : BORDER
}`,
background: disabled
? "#F6F8FA"
: selected
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
: "#FFFFFF",
boxShadow: selected
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
: "0 1px 2px rgba(16,24,40,0.04)",
opacity: disabled ? 0.65 : 1,
}}
onMouseEnter={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = "#BFE3D2";
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
}
}}
onMouseLeave={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = BORDER;
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
}
}}
>
{selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-white" />
<span
style={{
position: "absolute",
right: 14,
top: 14,
display: "flex",
height: 22,
width: 22,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: GREEN,
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
}}
>
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
</span>
)}
{/* Structured form (icon + title + description) */}
{(icon || title || description) && (
<Box>
{icon && (
<Box
style={{
marginBottom: 12,
display: "flex",
height: 42,
width: 42,
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
)}
{title && (
<Text fz={15} fw={800} c={INK}>
{title}
</Text>
)}
{description && (
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
{description}
</Text>
)}
</Box>
)}
{children}
</button>
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
<Alert color={color} icon={icon} radius="md" fz="sm">
<Alert color={color} icon={icon} radius="lg" fz="sm">
{children}
</Alert>
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
<Text
fz={11}
fw={700}
tt="uppercase"
c={MUTED}
style={{ letterSpacing: "0.07em" }}
>
{children}
</Text>
);
}
/**
* Card shell that wraps a step's body. Gives every step the same premium
* surface, padding, and an optional eyebrow.
*/
export function StepCard({
children,
eyebrow,
}: {
children: ReactNode;
eyebrow?: ReactNode;
}) {
return (
<Paper
radius={20}
p={{ base: "lg", sm: 28 }}
withBorder
bg="white"
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
>
{eyebrow}
{children}
</Paper>
);
}
export function StepHeader({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<div>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
{icon && (
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
color: GREEN_DARK,
}}
>
{icon}
</Box>
)}
<Box>
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
{title}
</Title>
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
{description}
</Text>
</Box>
</Group>
);
}
/** Shared Mantine input styling so every field in the form matches. */
export const fieldStyles = {
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
} as const;
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
leftSection,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
leftSection?: ReactNode;
}) {
return (
<Select
@@ -122,6 +301,11 @@ export function SelectField({
onBlur={field.onBlur}
error={error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
leftSection={leftSection}
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
<Input.Wrapper label={label} error={error?.message}>
<Combobox store={combobox} disabled={disabled}>
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
<Combobox.Target>
<InputBase
placeholder={placeholder}
disabled={disabled}
radius={10}
styles={fieldStyles}
value={searchQuery || selectedLabel}
onChange={(e) => {
onSearchChange(e.currentTarget.value);

View File

@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Shipment Documents"
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
<Group
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
/>
)}
/>
</div>
</StepCard>
);
}

View File

@@ -5,10 +5,9 @@ import {
Button,
Card,
Group,
Modal,
Stack,
Text,
useMantineTheme
useMantineTheme,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[];
hasSchedule: boolean;
/** True when the route has at least one departure on this day. */
hasDeparture: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
// days have a departure — no capacity, no per-train detail. The batch engine
// assigns the train later, distributing the day's pool by priority.
const { data: availableDays } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
// Group all schedules per date — multiple departures per day are allowed.
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
// match the format used by the calendar day keys.
const schedulesByDate = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem[]>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
const dateKey = s.scheduleDate.slice(0, 10);
const existing = map.get(dateKey) ?? [];
map.set(dateKey, [...existing, s]);
}
}
return map;
}, [bookableSchedules]);
const selectedSchedule = useMemo(
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
[bookableSchedules, selectedScheduleId],
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
schedules,
hasSchedule: schedules.length > 0,
hasDeparture: departureDays.has(dateString),
};
});
}, [currentDate, schedulesByDate, selectedDate]);
}, [currentDate, departureDays, selectedDate]);
const availableCount = useMemo(
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
[days],
);
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) {
setSelectedDayForModal(day);
} else if (day.schedules.length === 1) {
form.setValue("scheduledDate", day.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", day.schedules[0].id, {
shouldValidate: true,
});
}
};
const handleSelectScheduleFromModal = (scheduleId: string) => {
if (selectedDayForModal) {
form.setValue("scheduledDate", selectedDayForModal.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
setSelectedDayForModal(null);
}
if (!day.hasDeparture) return;
// Record only the day — no specific train is chosen.
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
};
return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
</Text>
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell
key={di}
day={d}
onDayClick={handleDayClick}
/>
<DayCell key={di} day={d} onDayClick={handleDayClick} />
))}
</Box>
))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary}
/>
{selectedSchedule && selectedDate && (
{selectedDate && (
<Box
p={14}
style={{
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DEPARTURE
SELECTED DAY
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Train
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
</Text>
</Group>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Wagons available
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
</Text>
</Group>
<Text fz={12.5} c="edr-muted">
Your train is confirmed by our freight desk after booking.
</Text>
</Stack>
</Box>
)}
@@ -380,71 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack>
</Box>
</Stack>
{/* ── Schedule Selection Modal ──────────────────────────── */}
<Modal
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered
size="sm"
styles={{
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
body: { padding: 24 },
}}
>
<Stack gap={12}>
<Text fz={13} c="edr-muted" fw={500}>
Choose a departure time
</Text>
{selectedDayForModal?.schedules.map((schedule) => (
<Button
key={schedule.id}
variant="outline"
fullWidth
onClick={() => handleSelectScheduleFromModal(schedule.id)}
style={{ height: 64, justifyContent: "flex-start" }}
styles={{
inner: { justifyContent: "flex-start" },
root: {
borderColor: theme.colors["edr-border"][0],
transition: "all 150ms ease",
"&:hover": {
borderColor: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-soft"][0],
},
},
}}
>
<Group gap={16} w="100%">
<Box
style={{
width: 48,
height: 48,
borderRadius: theme.radius.md,
backgroundColor: theme.colors["edr-soft"][0],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][5]} />
</Box>
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
<Text fw={700} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
{schedule.trainNumber && (
<Text fz={12} c="edr-muted">
Train {schedule.trainNumber}
</Text>
)}
</Stack>
</Group>
</Button>
))}
</Stack>
</Modal>
</Group>
);
}
@@ -454,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void;
}
function DayCell({ day: d, onDayClick, }: DayCellProps) {
function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -478,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
: d.hasSchedule
: d.hasDeparture
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
: d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
<Box
onClick={() => d.hasSchedule && onDayClick(d)}
onClick={() => d.hasDeparture && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
@@ -501,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex",
flexDirection: "column",
gap: 4,
cursor: d.hasSchedule ? "pointer" : "default",
cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease",
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
@@ -527,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
: d.hasSchedule
: d.hasDeparture
? "edr-text.0"
: "edr-muted"
}
@@ -552,45 +430,25 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center",
}}
>
<Check
size={14}
color="white"
strokeWidth={3}
/>
<Check size={14} color="white" strokeWidth={3} />
</Box>
)}
</Group>
{/* Schedule times */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => (
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
<Box
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={12}
fw={700}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
>
{format(new Date(s.scheduleDate), "HH:mm")}
</Text>
</Group>
))}
{d.schedules.length > 2 && (
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
+{d.schedules.length - 2} more
</Text>
)}
</Stack>
{/* Availability marker — a single dot for days that have a departure.
No counts or capacity are shown: it's a day-level pool. */}
{d.hasDeparture && (
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
</Box>
)}
</Box>
);

View File

@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -172,8 +174,8 @@ export function Step1ContractType({
form.setValue("containers", mappedContainers);
}
// ── Consolidation ───────────────────────────────────────────────────
form.setValue("consolidationEnabled", booking.allowConsolidation);
// Consolidation is system-managed (always allowed) — not copied from the
// previous booking and not customer-controllable.
// ── Scheduled date ──────────────────────────────────────────────────
if (booking.scheduledDate) {
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="New contract or renewal of an existing one."
description="Start a new contract or renew an existing one to reuse its details."
/>
<Controller
@@ -193,40 +196,33 @@ export function Step1ContractType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
icon={<FileText className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="New Contract"
description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
/>
<OptionCard
selected={field.value === "renewal"}
icon={<RefreshCw className="h-5 w-5" />}
iconBg="#EAF1FB"
iconColor="#2E5B96"
title="Contract Renewal"
description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
<Stack gap={12} mt={22}>
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
</AlertBox>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}

View File

@@ -1,9 +1,18 @@
import { Switch, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react";
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Layers size={22} />}
title="Service Type"
description="Select the service combination and configure trucking options."
description="Choose the service combination, then configure your trucking options."
/>
<Controller
@@ -72,211 +82,225 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => {
return (
<OptionCard
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">{s.serviceName}</p>
<p className="mt-0.5 text-xs text-gray-500">
{s.description}
</p>
</OptionCard>
);
})}
.map((s) => (
<OptionCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
<PaymentCurrencyField control={form.control} />
{showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
<Stack gap={12} mt={24}>
<StepLabel>Trucking & customs options</StepLabel>
{/* First Mile */}
{includesFirstMile && (
<div className="p-4">
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile Pick-up
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="First Mile — Pick-up"
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Last Mile */}
{includesLastMile && (
<div className="p-4">
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile Delivery
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onChange={(e) => {
field.onChange(
e.currentTarget.checked
? "with_return"
: "without_return",
);
}}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Equipment Return"
description={
field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."
}
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
{/* Customs Clearing */}
{includesCustoms && (
<div className="p-4">
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on
your behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}
function ServiceToggle({
icon,
title,
description,
checked,
onChange,
children,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
children?: ReactNode;
}) {
return (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#ECF6F1" : "#F1F4F7",
color: checked ? "#0A6F4D" : "#64748B",
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
style={{ flexShrink: 0 }}
/>
</Group>
{children}
</Box>
);
}

View File

@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -35,10 +35,17 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// The form keys shipping line by name, so options are keyed by name too.
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const originData = useMemo(() => {
@@ -63,7 +70,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +91,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<RouteIcon size={22} />}
title="Route"
description="Select the origin and destination yards."
description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +103,7 @@ export function Step4Route({
) : (
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
@@ -153,56 +160,108 @@ export function Step4Route({
/>
)}
<Divider />
<Divider my={22} color="#EEF2F6" />
<div className="divide-y divide-gray-200">
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge to the final bill."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
</div>
</Stack>
</StepCard>
);
}
function ToggleRow({
icon,
iconBg,
iconColor,
title,
description,
checked,
onChange,
}: {
icon: React.ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Group
justify="space-between"
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
/>
</Group>
);
}

View File

@@ -5,7 +5,6 @@ import {
ActionIcon,
Button,
Skeleton,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="lg" />
</div>
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
<Skeleton height={44} radius="md" />
<Skeleton height={44} w="33%" radius="md" />
</div>
</div>
</StepCard>
);
}
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<InputLabel>Cargo Type *</InputLabel>
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
/>
<OptionCard
selected={cargoType === "bulk"}
icon={<Weight className="h-5 w-5" />}
iconBg="#FDF3E0"
iconColor="#C77F09"
title="General Cargo"
description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
/>
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()}
</>
)}
</div>
</StepCard>
);
}

View File

@@ -1,26 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
Badge,
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Paper,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
Circle,
ClipboardCheck,
FileText,
Package,
Pencil,
Route,
Send,
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
schedule: 5,
documents: 6,
} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
function OverviewSection({
icon,
title,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Box
className="flex items-center justify-center rounded-lg"
style={{
width: 36,
height: 36,
backgroundColor: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{icon}
</Box>
<Text fw={700} size="sm" c="#10202F">
{title}
</Text>
</Group>
<Button
type="button"
variant="subtle"
color="edr-green"
size="compact-xs"
leftSection={<Pencil size={13} />}
onClick={onEdit}
>
Edit
</Button>
</Group>
{children}
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" className="tracking-wide">
{label}
</Text>
<Text size="sm" fw={500} ta="right" maw="60%">
{value || "—"}
</Text>
</Group>
);
}
function ReadinessItem({
done,
label,
}: {
done: boolean;
label: string;
}) {
return (
<Group gap="sm" wrap="nowrap">
{done ? (
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
) : (
<Circle size={18} className="shrink-0 text-gray-300" />
)}
<Text size="sm" c={done ? "dark" : "dimmed"}>
{label}
</Text>
</Group>
);
}
export function Step8Review({
form,
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
onSaveDraft,
onSubmit,
saveDraftPending = false,
submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
onSaveDraft?: () => void;
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function CompactRow({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label}
</Text>
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
);
}
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary;
if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -143,226 +185,312 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
const destinationYardName =
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
values.destinationYard;
const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
: "—";
const directionLabel = direction
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
return (
<Stack gap="md">
<Stack gap="lg">
<StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
description="Review your booking overview before sending it for EDR staff review."
/>
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left — booking summary */}
<Stack gap="md" className="min-w-0 flex-1">
<Paper
radius={20}
p="lg"
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
Booking overview
</Text>
<Text fw={800} size="xl" c="#10202F">
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
</Text>
<Text size="sm" c="dimmed">
{serviceType?.name ?? "—"} · {originYardName} {destinationYardName}
</Text>
</Stack>
<Badge size="lg" variant="light" color="edr-green" radius="md">
{directionLabel}
</Badge>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
</Paper>
<OverviewSection
icon={<Package size={18} />}
title="Contract & Service"
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
>
<DetailRow
label="Contract"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
/>
{values.contractType === "renewal" && values.previousContractRef && (
<DetailRow label="Previous ref" value={values.previousContractRef} />
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<CompactCard icon={<Route size={16} />} title="Route">
<CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile"
value={
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
<DetailRow label="Service" value={serviceType?.name ?? ""} />
<DetailRow
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
/>
<Button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
variant="subtle"
size="compact-xs"
color="gray"
mt={4}
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid>
Edit service options
</Button>
</OverviewSection>
{/* Notes */}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations…"
rows={2}
radius="md"
size="sm"
<OverviewSection
icon={<Route size={18} />}
title="Route"
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
>
<DetailRow
label="Corridor"
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
<DetailRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
/>
</OverviewSection>
<OverviewSection
icon={<Truck size={18} />}
title="Logistics"
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
>
<DetailRow
label="First mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
/>
<DetailRow
label="Last mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
/>
<DetailRow
label="Equipment return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
</OverviewSection>
<OverviewSection
icon={<Package size={18} />}
title="Cargo"
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{values.containers
.filter((c) => +c.qty > 0)
.map((c, i) => (
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{containerSummary && (
<DetailRow label="Summary" value={containerSummary} />
)}
</OverviewSection>
<OverviewSection
icon={<FileText size={18} />}
title="Documents"
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{BOOKING_DOCS_SETTING.fields.map((field) => {
const file = documents[field.fileKey];
const attached = Array.isArray(file)
? file.length > 0
: Boolean(file);
const fileName = attached
? Array.isArray(file)
? file[0]?.name
: (file as File)?.name
: null;
return (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
</Text>
</Group>
);
})}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
</Text>
</OverviewSection>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/>
)}
/>
</Stack>
{/* Right — sticky actions */}
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
<Stack gap="md">
<Paper radius={20} p="lg" withBorder bg="white">
<Text fw={800} size="sm" mb="md" c="#10202F">
Submission readiness
</Text>
<Stack gap="sm">
<ReadinessItem done={Boolean(values.serviceTypeId)} label="Service configured" />
<ReadinessItem
done={Boolean(values.paymentCurrency)}
label="Payment currency selected"
/>
<ReadinessItem
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
<ReadinessItem
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
}
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{allDocsReady
? "Ready to submit. You'll review the price estimate before final submission."
: "Upload all four documents to enable submission."}
</Text>
<Stack gap="sm">
<Button
type="button"
color="edr-green"
radius="md"
fullWidth
size="md"
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
>
Submit
</Button>
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
</Button>
</Stack>
</Paper>
</Stack>
</Box>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { Freight } from "@edr/types";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
return (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /payments/initiate, and redirects the browser to the provider (or the
* fallback checkout page). Reused by the booking detail page, the booking list,
* and the home page so "Pay now" behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId, method }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId, method });
window.location.href = redirectUrl;
},
});
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
setModalOpen(false);
mutation.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
};
}

View File

@@ -0,0 +1,752 @@
import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Clock,
Flag,
MapPin,
PackageX,
RefreshCw,
Train,
} from "lucide-react";
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import {
checkpointKindLabel,
corridorProgress,
isArrived,
isDispatched,
shipmentStatusLabel,
} from "./trackingStages";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const ACCENT = "#F2A516";
const INK = "#10202F";
const MUTED = "#6B7C8E";
interface ShipmentTrackingModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
bookingReference: string;
originLabel?: string;
destinationLabel?: string;
}
export function ShipmentTrackingModal({
opened,
onClose,
bookingId,
bookingReference,
originLabel,
destinationLabel,
}: ShipmentTrackingModalProps) {
const { data, isLoading, isError, refetch, isFetching } = useQuery({
...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
enabled: opened && Boolean(bookingId),
refetchInterval: opened ? 30_000 : false,
});
const hasSchedule = data?.hasSchedule ?? false;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size={900}
radius={20}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ content: { overflow: "hidden" } }}
>
<Header
bookingReference={data?.bookingReference ?? bookingReference}
trainNumber={data?.trainNumber ?? null}
status={data?.scheduleStatus ?? null}
currentSequenceNo={data?.currentSequenceNo ?? -1}
onClose={onClose}
onRefresh={() => refetch()}
refreshing={isFetching}
/>
<Box px={28} py={24}>
{isLoading ? (
<Center mih={280}>
<Stack align="center" gap="sm">
<Loader color="edr-green" />
<Text fz="sm" c={MUTED}>
Locating your train
</Text>
</Stack>
</Center>
) : isError ? (
<ErrorState onRetry={() => refetch()} />
) : !hasSchedule ? (
<NotDispatchedState
origin={originLabel ?? "Origin"}
destination={destinationLabel ?? "Destination"}
/>
) : data ? (
<Stack gap={26}>
<SummaryBar data={data} />
<Corridor data={data} />
<CheckpointFeed data={data} />
</Stack>
) : null}
</Box>
</Modal>
);
}
// ── Header ────────────────────────────────────────────────────────────────────
function Header({
bookingReference,
trainNumber,
status,
currentSequenceNo,
onClose,
onRefresh,
refreshing,
}: {
bookingReference: string;
trainNumber: string | null;
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
onClose: () => void;
onRefresh: () => void;
refreshing: boolean;
}) {
return (
<Box
px={28}
py={22}
style={{
background:
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={14} align="center" wrap="nowrap">
<Box
style={{
width: 48,
height: 48,
borderRadius: 13,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.12)",
color: "#fff",
}}
>
<Train size={24} />
</Box>
<Box>
<Text
fz="11px"
fw={700}
tt="uppercase"
c="#9FE9CC"
style={{ letterSpacing: 0.7 }}
>
Live shipment tracking
</Text>
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
{bookingReference}
</Text>
{trainNumber && (
<Text fz="12px" c="#A9BBCB">
Train {trainNumber}
</Text>
)}
</Box>
</Group>
<Group gap={10} align="center" wrap="nowrap">
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
<RefreshCw size={16} />
</IconButton>
<IconButton title="Close" onClick={onClose}>
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
</IconButton>
</Group>
</Group>
</Box>
);
}
function IconButton({
children,
onClick,
title,
spinning,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
spinning?: boolean;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
style={{
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: "1px solid rgba(255,255,255,0.18)",
backgroundColor: "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
}}
>
{children}
</button>
);
}
function HeaderStatusPill({
status,
currentSequenceNo,
}: {
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
}) {
const arrived = isArrived(status);
const moving = isDispatched(status);
const bg = arrived
? "rgba(14,163,113,0.22)"
: moving
? "rgba(242,165,22,0.20)"
: "rgba(255,255,255,0.12)";
const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
return (
<Group
gap={7}
align="center"
wrap="nowrap"
px={12}
py={7}
style={{ borderRadius: 999, backgroundColor: bg }}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: dot,
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
}}
/>
<Text fz="12px" fw={700} c="#fff">
{shipmentStatusLabel(status, currentSequenceNo)}
</Text>
</Group>
);
}
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const items: Array<{ label: string; value: string; accent?: boolean }> = [
{
label: "Departed",
value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
},
{
label: arrived ? "Arrived" : "Est. arrival",
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
accent: !arrived,
},
{
label: "Stations",
value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
},
];
return (
<Group
gap={0}
wrap="nowrap"
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
overflow: "hidden",
}}
>
{items.map((it, i) => (
<Box
key={it.label}
style={{
flex: 1,
padding: "14px 16px",
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
background: it.accent ? "#FEFBF3" : "#FBFCFD",
}}
>
<Text
fz="10.5px"
fw={700}
tt="uppercase"
c={it.accent ? "#B07D14" : MUTED}
style={{ letterSpacing: 0.5 }}
>
{it.label}
</Text>
<Text fz="15px" fw={800} c={INK} mt={2}>
{it.value}
</Text>
</Box>
))}
</Group>
);
}
// ── Corridor: stations + train marker ──────────────────────────────────────────
function Corridor({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const moving = isDispatched(data.scheduleStatus);
const stations = data.stations;
const current = data.currentSequenceNo;
const progress = corridorProgress(stations.length, current, arrived);
// Map sequenceNo → latest checkpoint at that station for captions.
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
return (
<Box>
<Group gap={8} align="center" mb={16}>
<MapPin size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Where is your train
</Text>
<Text fz="12.5px" c={MUTED}>
· {progress}% of the route
</Text>
</Group>
{/* Horizontal rail */}
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
{/* base rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
right: 16,
height: 5,
borderRadius: 999,
background: "#EAF0F5",
}}
/>
{/* filled rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
width: `calc((100% - 32px) * ${progress / 100})`,
height: 5,
borderRadius: 999,
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
transition: "width 600ms ease",
}}
/>
{/* train marker riding the filled rail */}
<Box
style={{
position: "absolute",
top: 18,
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
transform: "translateX(-50%)",
transition: "left 600ms ease",
zIndex: 3,
}}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: arrived
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
color: "#fff",
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
border: "3px solid #fff",
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
}}
>
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
</Box>
</Box>
{/* station nodes */}
<Box
style={{
position: "relative",
display: "flex",
justifyContent: "space-between",
zIndex: 2,
}}
>
{stations.map((s, i) => {
const reached = arrived || (current >= 0 && i <= current);
const isCurrent = !arrived && i === current;
const isLast = i === stations.length - 1;
const cp = checkpointBySeq.get(s.sequenceNo);
return (
<StationNode
key={`${s.yardId}-${i}`}
label={s.label}
reached={reached}
isCurrent={isCurrent}
isEndpoint={i === 0 || isLast}
arrivedHere={isLast && arrived}
time={cp ? fmtTime(cp.occurredAt) : null}
align={i === 0 ? "left" : isLast ? "right" : "center"}
/>
);
})}
</Box>
</Box>
</Box>
);
}
function StationNode({
label,
reached,
isCurrent,
isEndpoint,
arrivedHere,
time,
align,
}: {
label: string;
reached: boolean;
isCurrent: boolean;
isEndpoint: boolean;
arrivedHere: boolean;
time: string | null;
align: "left" | "center" | "right";
}) {
const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
return (
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
flex: isEndpoint ? "0 0 auto" : 1,
minWidth: 0,
maxWidth: 120,
}}
>
<Box
style={{
width: isCurrent ? 18 : 14,
height: isCurrent ? 18 : 14,
borderRadius: "50%",
background: "#fff",
border: `3px solid ${color}`,
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: isCurrent ? 7 : 5,
height: isCurrent ? 7 : 5,
borderRadius: "50%",
background: color,
}}
/>
</Box>
<Text
fz="11.5px"
fw={reached ? 700 : 600}
c={reached ? INK : "#9AA8B5"}
mt={8}
ta={align}
truncate
style={{ maxWidth: 110 }}
title={label}
>
{label}
</Text>
{time && (
<Text fz="10px" c={MUTED} mt={1}>
{time}
</Text>
)}
</Box>
);
}
// ── Checkpoint feed ────────────────────────────────────────────────────────────
function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
// Newest first.
const ordered = [...data.checkpoints].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
return (
<Box
p={20}
style={{
borderRadius: 16,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
<Clock size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Journey log
</Text>
</Group>
{ordered.length === 0 ? (
<Text fz="13px" c={MUTED}>
No checkpoints logged yet. Updates appear here as the train passes each
station along the corridor.
</Text>
) : (
<Box>
{ordered.map((cp, i) => {
const isLatest = i === 0;
const last = i === ordered.length - 1;
const Icon =
cp.kind === Freight.TrainCheckpointKind.Arrived
? CheckCircle2
: cp.kind === Freight.TrainCheckpointKind.Departed
? Flag
: Train;
return (
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
alignSelf: "stretch",
}}
>
<Box
style={{
width: 30,
height: 30,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#64748B",
flexShrink: 0,
}}
>
<Icon size={15} />
</Box>
{!last && (
<Box
style={{
flex: 1,
width: 2,
marginTop: 4,
marginBottom: 4,
background: "#E1E7EE",
}}
/>
)}
</Box>
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz="13.5px" fw={700} c={INK}>
{cp.label ?? "Checkpoint"}
</Text>
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#475569",
}}
>
{checkpointKindLabel(cp.kind)}
</Box>
{isLatest && (
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: "#FEF6E6",
color: "#B07D14",
}}
>
Latest
</Box>
)}
</Group>
{cp.note && (
<Text fz="12.5px" c={MUTED} mt={2}>
{cp.note}
</Text>
)}
<Text fz="11.5px" c="#9AA8B5" mt={3}>
{fmtTime(cp.occurredAt)}
</Text>
</Box>
</Group>
);
})}
</Box>
)}
</Box>
);
}
// ── Empty / error states ───────────────────────────────────────────────────────
function NotDispatchedState({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Stack align="center" gap={6} py={40} ta="center">
<Box
style={{
width: 64,
height: 64,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF6E6",
color: ACCENT,
}}
>
<PackageX size={30} />
</Box>
<Text fz="18px" fw={800} c={INK} mt={4}>
Not on the rails yet
</Text>
<Text fz="13.5px" c={MUTED} maw={440}>
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
assigned to a train. Live tracking begins the moment it's dispatched and
starts moving along the corridor.
</Text>
</Stack>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Stack align="center" gap={8} py={40} ta="center">
<Box
style={{
width: 60,
height: 60,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={28} />
</Box>
<Text fz="16px" fw={800} c={INK}>
Couldn't load tracking
</Text>
<Text fz="13px" c={MUTED}>
Something went wrong fetching your shipment status.
</Text>
<button
type="button"
onClick={onRetry}
style={{
marginTop: 6,
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 16px",
borderRadius: 10,
border: "1px solid #E6ECF2",
background: "#fff",
color: INK,
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
<RefreshCw size={15} /> Try again
</button>
</Stack>
);
}
// ── helpers ────────────────────────────────────────────────────────────────────
function fmtTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// keyframes (injected once)
if (
typeof document !== "undefined" &&
!document.getElementById("edr-tracking-kf")
) {
const style = document.createElement("style");
style.id = "edr-tracking-kf";
style.textContent = `
@keyframes edr-spin { to { transform: rotate(360deg); } }
@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
`;
document.head.appendChild(style);
}

View File

@@ -0,0 +1,64 @@
import { Freight } from "@edr/types";
const { TrainScheduleStatus } = Freight;
export function isArrived(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Arrived;
}
export function isDispatched(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Dispatched;
}
/** Human label for the schedule status, from the rider's point of view. */
export function shipmentStatusLabel(
status?: Freight.TrainScheduleStatus | null,
currentSequenceNo = -1,
): string {
switch (status) {
case TrainScheduleStatus.Arrived:
return "Arrived";
case TrainScheduleStatus.Dispatched:
return currentSequenceNo <= 0 ? "Departed" : "In transit";
case TrainScheduleStatus.Scheduled:
return "Scheduled";
case TrainScheduleStatus.Cancelled:
return "Cancelled";
case TrainScheduleStatus.Draft:
return "Preparing";
default:
return "Not dispatched";
}
}
/**
* 0100 progress across the corridor, derived from how many stations the train
* has reached. Arrived → 100. Not departed → 0.
*/
export function corridorProgress(
stationCount: number,
currentSequenceNo: number,
arrived: boolean,
): number {
if (arrived) return 100;
if (stationCount <= 1 || currentSequenceNo < 0) return 0;
const lastSeq = stationCount - 1;
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
}
/** Caption for a checkpoint kind. */
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
switch (kind) {
case Freight.TrainCheckpointKind.Departed:
return "Departed";
case Freight.TrainCheckpointKind.Arrived:
return "Arrived";
case Freight.TrainCheckpointKind.Passed:
default:
return "Passed";
}
}

View File

@@ -1,8 +1,16 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { Button } from "@edr/ui-common";
import {
Box,
Button,
Divider,
Loader,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react";
import { api } from "@/services/api";
function extractOrderId(): string | null {
@@ -13,6 +21,35 @@ function extractOrderId(): string | null {
return segments[segments.length - 1] ?? null;
}
function PaymentCard({ children }: { children: React.ReactNode }) {
return (
<Box
style={{
minHeight: "100dvh",
background: "#f8fafc",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<Box
style={{
width: "100%",
maxWidth: 460,
background: "#fff",
borderRadius: 24,
border: "1.5px solid #e5e7eb",
boxShadow: "0 4px 24px 0 rgba(0,0,0,0.07)",
overflow: "hidden",
}}
>
{children}
</Box>
</Box>
);
}
export default function CheckPaymentPage() {
const navigate = useNavigate();
const orderId = useMemo(() => extractOrderId(), []);
@@ -29,105 +66,307 @@ export default function CheckPaymentPage() {
if (!orderId) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
<div className="flex flex-col items-center gap-4">
<XCircle className="size-10 text-destructive" />
<p className="text-lg font-bold text-foreground">
No payment reference found
</p>
<Button
type="button"
variant="outline"
onClick={() => navigate("/bookings")}
>
Back to My Bookings
</Button>
</div>
</div>
</div>
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #f97316 0%, #fb923c 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<FileSearch size={42} color="#fff" />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
No payment reference
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8}>
We could not find a payment order to verify.
</Text>
</Box>
<Stack gap={10} p={32}>
<Button
fullWidth
size="md"
radius={12}
color="orange"
onClick={() => navigate("/bookings")}
styles={{ root: { height: 48, fontWeight: 700 } }}
>
Back to my bookings
</Button>
</Stack>
</PaymentCard>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
{isLoading && (
<div className="flex flex-col items-center gap-4">
<LoaderCircle className="size-10 animate-spin text-primary" />
<p className="text-lg font-semibold text-foreground">
Checking payment status
</p>
</div>
)}
if (isLoading) {
return (
<PaymentCard>
<Box style={{ padding: "64px 32px", textAlign: "center" }}>
<Loader size={48} color="edr-green" type="dots" mx="auto" mb={24} />
<Text fw={700} fz={18} c="#10202F">
Verifying your payment
</Text>
<Text fz={14} c="dimmed" mt={8}>
Please wait, this usually takes a few seconds.
</Text>
</Box>
</PaymentCard>
);
}
{isSuccess && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
<CheckCircle2 className="size-8 text-primary" />
</div>
<p className="text-lg font-bold text-foreground">
Payment was successful!
</p>
<p className="text-sm text-muted-foreground">
Your booking has been confirmed and payment is complete.
</p>
if (isSuccess) {
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.2)",
border: "2px solid rgba(255,255,255,0.35)",
margin: "0 auto 20px",
display: "flex",
}}
>
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment verified
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Your booking is confirmed and payment is complete.
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#14532d" lh={1.5} ta="center">
EDR staff will assign a train and you will be notified of any updates.
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
type="button"
fullWidth
size="md"
radius={12}
color="edr-green"
leftSection={<FileText size={17} />}
onClick={() => navigate("/bookings")}
className="mt-2"
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Go to My Bookings
Go to my bookings
</Button>
</div>
)}
{!isLoading && data && !isSuccess && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<p className="text-lg font-bold text-foreground">
Payment status: {data.status}
</p>
<p className="text-sm text-muted-foreground">
Please try again or contact support if the issue persists.
</p>
<Button
type="button"
variant="outline"
onClick={() => navigate("/bookings")}
className="mt-2"
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to My Bookings
Back to home
</Button>
</div>
)}
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Questions?{" "}
<Text span c="edr-green" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}
{isError && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<p className="text-lg font-bold text-foreground">
Something went wrong
</p>
<p className="text-sm text-muted-foreground">
if (isError) {
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Verification failed
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
We could not verify your payment status.
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#fff7f7",
border: "1px solid #fecaca",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#7f1d1d" ta="center" lh={1.5}>
{error instanceof Error
? error.message
: "Failed to check payment status."}
</p>
: "An unexpected error occurred. Please try again or contact support."}
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
type="button"
variant="outline"
fullWidth
size="md"
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
className="mt-2"
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to My Bookings
Back to my bookings
</Button>
</div>
)}
</div>
</div>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Need help?{" "}
<Text span c="red.6" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}
// Non-success status (e.g. PAY_FAIL, PENDING, etc.)
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #d97706 0%, #f59e0b 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment incomplete
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Status:{" "}
<Text span fw={700}>
{data?.status ?? "Unknown"}
</Text>
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#78350f" ta="center" lh={1.5}>
Your payment did not complete successfully. Nothing has been charged.
You can retry from your booking page.
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
fullWidth
size="md"
radius={12}
color="orange"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to my bookings retry payment
</Button>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Need help?{" "}
<Text span c="orange.7" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}

View File

@@ -0,0 +1,171 @@
import {
Box,
Button,
Divider,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
import { useNavigate } from "react-router-dom";
export default function PaymentFailurePage() {
const navigate = useNavigate();
return (
<Box
style={{
minHeight: "100dvh",
background: "linear-gradient(135deg, #fff7f7 0%, #f8fafc 60%, #fef2f2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<Box
style={{
width: "100%",
maxWidth: 460,
background: "#fff",
borderRadius: 24,
border: "1.5px solid #fecaca",
boxShadow:
"0 4px 24px 0 rgba(220,38,38,0.07), 0 1px 4px 0 rgba(0,0,0,0.04)",
overflow: "hidden",
}}
>
{/* Red header stripe */}
<Box
style={{
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment not completed
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Nothing was charged your booking is still active.
</Text>
</Box>
{/* Body */}
<Stack gap={0} p={32}>
<Stack gap={16}>
<Box
style={{
background: "#fff7f7",
border: "1px solid #fecaca",
borderRadius: 14,
padding: "16px 20px",
}}
>
<Stack gap={10}>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
Your payment was declined or cancelled. No charge was made.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
You can retry using the <strong>Pay now</strong> button on
your booking page.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
Contact support if the problem persists.
</Text>
</Group>
</Stack>
</Box>
</Stack>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
fullWidth
size="md"
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
styles={{
root: { height: 48, fontWeight: 700, fontSize: 15 },
}}
>
Back to my bookings retry payment
</Button>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{
root: { height: 44, fontWeight: 600, fontSize: 14 },
}}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
Need help?{" "}
<Text span c="red.6" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</Box>
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show More