mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -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")
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -44,7 +44,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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
@@ -56,6 +55,7 @@ import { ContainersModule } from './modules/container-management/containers.modu
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -113,12 +113,12 @@ import { OverviewModule } from './modules/overview/overview.module';
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
DemoBookingsSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
@@ -131,9 +131,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 freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -144,11 +141,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();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plate_number VARCHAR NOT NULL UNIQUE,
|
||||
registration_number VARCHAR NOT NULL UNIQUE,
|
||||
vehicle_type VARCHAR NOT NULL,
|
||||
manufacturer VARCHAR NOT NULL,
|
||||
model VARCHAR NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
fuel_type VARCHAR NOT NULL,
|
||||
capacity NUMERIC NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
|
||||
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
|
||||
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
|
||||
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
|
||||
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -52,6 +56,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 +152,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 +203,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 +216,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. */
|
||||
@@ -575,6 +565,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 +578,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 +624,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;
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -721,27 +721,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 +785,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 +808,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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
|
||||
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
export class CreateVehicleDto {
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@IsEnum(VehicleType)
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@IsString()
|
||||
manufacturer!: string;
|
||||
|
||||
@IsString()
|
||||
model!: string;
|
||||
|
||||
@IsNumber()
|
||||
year!: number;
|
||||
|
||||
@IsEnum(FuelType)
|
||||
fuelType!: FuelType;
|
||||
|
||||
@IsNumber()
|
||||
capacity!: number;
|
||||
|
||||
@IsEnum(VehicleStatus)
|
||||
status!: VehicleStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||
|
||||
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Entity, Column, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
export enum VehicleType {
|
||||
TRUCK = 'TRUCK',
|
||||
VAN = 'VAN',
|
||||
CAR = 'CAR',
|
||||
BUS = 'BUS',
|
||||
TRAILER = 'TRAILER',
|
||||
TANKER = 'TANKER',
|
||||
FLATBED = 'FLATBED',
|
||||
}
|
||||
|
||||
export enum FuelType {
|
||||
PETROL = 'PETROL',
|
||||
DIESEL = 'DIESEL',
|
||||
ELECTRIC = 'ELECTRIC',
|
||||
HYBRID = 'HYBRID',
|
||||
}
|
||||
|
||||
export enum VehicleStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
MAINTENANCE = 'MAINTENANCE',
|
||||
RETIRED = 'RETIRED',
|
||||
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicles', schema: 'freight' })
|
||||
@Index(['plateNumber'])
|
||||
@Index(['registrationNumber'])
|
||||
@Index(['status'])
|
||||
@Index(['vehicleType'])
|
||||
@Index(['manufacturer'])
|
||||
export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'plate_number', unique: true })
|
||||
plateNumber!: string;
|
||||
|
||||
@Column({ name: 'registration_number', unique: true })
|
||||
registrationNumber!: string;
|
||||
|
||||
@Column({ name: 'vehicle_type', type: 'varchar' })
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@Column()
|
||||
manufacturer!: string;
|
||||
|
||||
@Column()
|
||||
model!: string;
|
||||
|
||||
@Column()
|
||||
year!: number;
|
||||
|
||||
@Column({ name: 'fuel_type', type: 'varchar' })
|
||||
fuelType!: FuelType;
|
||||
|
||||
@Column()
|
||||
capacity!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
|
||||
status!: VehicleStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
|
||||
@ApiTags('vehicles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('vehicles')
|
||||
@FleetView()
|
||||
export class VehiclesController {
|
||||
constructor(private readonly vehiclesService: VehiclesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new vehicle' })
|
||||
create(@Body() createVehicleDto: CreateVehicleDto) {
|
||||
return this.vehiclesService.create(createVehicleDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all vehicles with filters' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||
) {
|
||||
return this.vehiclesService.findAll({
|
||||
search,
|
||||
status: status as any,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get vehicle by id' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a vehicle' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateVehicleDto: UpdateVehicleDto,
|
||||
) {
|
||||
return this.vehiclesService.update(id, updateVehicleDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a vehicle' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||
providers: [VehiclesService],
|
||||
controllers: [VehiclesController],
|
||||
exports: [VehiclesService],
|
||||
})
|
||||
export class VehiclesModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
repository: Repository<Vehicle>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
});
|
||||
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async findAll(query: {
|
||||
search?: string;
|
||||
status?: VehicleStatus | string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.status) where.status = query.status;
|
||||
|
||||
let qb = this.vehicleRepo.createQueryBuilder('v');
|
||||
|
||||
if (query.search) {
|
||||
qb = qb.where(
|
||||
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb = qb.andWhere('v.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
|
||||
query.sortBy ?? '',
|
||||
)
|
||||
? query.sortBy
|
||||
: 'createdAt';
|
||||
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
|
||||
|
||||
const [data, total] = await qb
|
||||
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Vehicle> {
|
||||
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
|
||||
if (!vehicle) {
|
||||
throw new NotFoundException(`Vehicle ${id} not found`);
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
|
||||
const vehicle = await this.findById(id);
|
||||
|
||||
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.vehicleRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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() {
|
||||
@@ -158,6 +160,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 />}>
|
||||
|
||||
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal 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 & Conditions
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Help & Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/logo.svg";
|
||||
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/logo.svg";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
}}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,20 @@ 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 ?? "",
|
||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||
scheduledDate: booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||
: "",
|
||||
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
||||
notes: "",
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
@@ -237,9 +265,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 +308,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 +342,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 +352,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,
|
||||
}));
|
||||
@@ -338,14 +376,9 @@ 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;
|
||||
|
||||
@@ -369,10 +402,15 @@ 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,
|
||||
trainScheduleId: data.trainScheduleId || undefined,
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
@@ -380,8 +418,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 +429,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)
|
||||
@@ -505,9 +542,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 +610,8 @@ export default function EditBookingPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<PaymentCurrencyField control={form.control} />
|
||||
|
||||
{(selectedService?.includesFirstMile ||
|
||||
selectedService?.includesLastMile ||
|
||||
selectedService?.includesCustoms) && (
|
||||
@@ -648,10 +712,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 +809,9 @@ export default function EditBookingPage() {
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 4: Cargo ── */}
|
||||
<Tabs.Panel value="cargo">
|
||||
<Box>
|
||||
<Step5CargoDetails
|
||||
form={form}
|
||||
@@ -758,10 +820,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 +920,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 +942,8 @@ export default function EditBookingPage() {
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Submit ── */}
|
||||
<Group
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -265,7 +264,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,7 +274,7 @@ 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!,
|
||||
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
|
||||
};
|
||||
}
|
||||
|
||||
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 +407,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 +422,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 +463,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 +513,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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
previousContractRef: "",
|
||||
|
||||
serviceTypeId: "",
|
||||
paymentCurrency: "USD",
|
||||
firstMile: {
|
||||
enabled: false,
|
||||
pickUpAddress: "",
|
||||
@@ -232,6 +252,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
1: ["contractType", "previousContractRef"],
|
||||
2: [
|
||||
"serviceTypeId",
|
||||
"paymentCurrency",
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
"equipmentReturn",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<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 },
|
||||
}}
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
<Text fz={13} c="edr-muted" fw={500}>
|
||||
Choose a departure time
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</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],
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
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>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Schedule times */}
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{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
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -63,7 +63,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 +84,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 +96,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 +153,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,320 @@ 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} />
|
||||
<DetailRow
|
||||
label="Train schedule"
|
||||
value={values.trainScheduleId ? "Selected" : "—"}
|
||||
/>
|
||||
</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` : "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Consolidation"
|
||||
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
|
||||
/>
|
||||
{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 && values.trainScheduleId)}
|
||||
label="Schedule 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 0–100 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { XCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a failed or
|
||||
* cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
|
||||
* charged and sends the customer back to their bookings to retry from "Pay now".
|
||||
*/
|
||||
export default function PaymentFailurePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
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">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-9 text-destructive" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment was not completed
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your payment didn't go through and you haven't been charged. You can
|
||||
try again from your booking using "Pay now".
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a successful
|
||||
* payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
|
||||
* customer back to their bookings, where the booking reflects the paid state.
|
||||
*/
|
||||
export default function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
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">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-9 text-primary" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment successful
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thank you — your payment has been received. Your booking will be
|
||||
updated shortly and is now confirmed for scheduling.
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Go to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
BookingListFilter,
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import {
|
||||
paymentsService,
|
||||
InitiatePaymentPayload,
|
||||
@@ -141,16 +143,29 @@ export const api = {
|
||||
({ id }) => bookingsService.get(id),
|
||||
),
|
||||
|
||||
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
||||
tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
|
||||
"bookings",
|
||||
"create",
|
||||
bookingsService.create,
|
||||
"tracking",
|
||||
({ id }) => bookingsService.tracking(id),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
>("bookings", "create", ({ payload, documents }) =>
|
||||
bookingsService.create(payload, documents),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: Partial<CreateBookingPayload> },
|
||||
{
|
||||
id: string;
|
||||
dto: Partial<CreateBookingPayload>;
|
||||
documents?: BookingDocuments;
|
||||
},
|
||||
{ booking: Freight.IBooking; warnings: string[] }
|
||||
>("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
|
||||
>("bookings", "update", ({ id, dto, documents }) =>
|
||||
bookingsService.update(id, dto, documents),
|
||||
),
|
||||
|
||||
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||
"bookings",
|
||||
@@ -174,12 +189,18 @@ export const api = {
|
||||
({ id }) => bookingsService.generatePrice(id),
|
||||
),
|
||||
|
||||
submit: endpoint<{ id: string }, Freight.IBooking>(
|
||||
submit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||
"bookings",
|
||||
"submit",
|
||||
({ id }) => bookingsService.submit(id),
|
||||
),
|
||||
|
||||
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||
"bookings",
|
||||
"confirmSubmit",
|
||||
({ id }) => bookingsService.confirmSubmit(id),
|
||||
),
|
||||
|
||||
uploadDocuments: endpoint<
|
||||
{ id: string; files: Record<string, File | File[] | null> },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CreateBookingPayload } from "./bookings.service";
|
||||
import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
|
||||
function appendValue(formData: FormData, key: string, value: unknown) {
|
||||
if (value === undefined || value === null) return;
|
||||
if (typeof value === "boolean") {
|
||||
formData.append(key, value ? "true" : "false");
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
formData.append(key, String(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function appendContainers(
|
||||
formData: FormData,
|
||||
containers: NonNullable<CreateBookingPayload["containers"]>,
|
||||
) {
|
||||
containers.forEach((container, index) => {
|
||||
formData.append(
|
||||
`containers[${index}][containerTypeId]`,
|
||||
container.containerTypeId,
|
||||
);
|
||||
formData.append(
|
||||
`containers[${index}][quantity]`,
|
||||
String(container.quantity),
|
||||
);
|
||||
formData.append(
|
||||
`containers[${index}][vgmPerUnitTons]`,
|
||||
String(container.vgmPerUnitTons),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function appendDocuments(
|
||||
formData: FormData,
|
||||
documents?: Record<string, File | File[] | null>,
|
||||
) {
|
||||
if (!documents) return;
|
||||
for (const [key, fileOrFiles] of Object.entries(documents)) {
|
||||
if (!fileOrFiles) continue;
|
||||
if (Array.isArray(fileOrFiles)) {
|
||||
for (const file of fileOrFiles) {
|
||||
formData.append(key, file);
|
||||
}
|
||||
} else {
|
||||
formData.append(key, fileOrFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten a booking payload (and optional document files) into multipart FormData. */
|
||||
export function buildBookingFormData(
|
||||
payload: Partial<CreateBookingPayload>,
|
||||
documents?: BookingDocuments,
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
const skipKeys = new Set(["containers", "freightShapeValidation"]);
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (skipKeys.has(key)) continue;
|
||||
appendValue(formData, key, value);
|
||||
}
|
||||
|
||||
if (payload.containers?.length) {
|
||||
appendContainers(formData, payload.containers);
|
||||
}
|
||||
|
||||
appendDocuments(formData, documents);
|
||||
return formData;
|
||||
}
|
||||
|
||||
/** Returns true when every required booking document field has a file attached. */
|
||||
export function hasAllRequiredDocuments(
|
||||
documents: BookingDocuments | undefined | null,
|
||||
): boolean {
|
||||
const docs = documents ?? {};
|
||||
return BOOKING_DOCS_SETTING.fields.every((field) => {
|
||||
const value = docs[field.fileKey];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return Boolean(value);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import { buildBookingFormData } from "./booking-form-data";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface SubmitBookingResponse {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
priceChanged: boolean;
|
||||
previousTotalAmount?: number;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
lineItems?: PriceLineItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
@@ -54,6 +67,8 @@ export interface SignContractPayload {
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
statuses?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
@@ -71,8 +86,18 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post("/api/bookings", payload);
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (
|
||||
payload: CreateBookingPayload,
|
||||
documents?: BookingDocuments,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const formData = buildBookingFormData(payload, documents);
|
||||
const { data } = await client.post("/api/bookings", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data.data.booking;
|
||||
},
|
||||
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||
@@ -82,8 +107,12 @@ export const bookingsService = {
|
||||
update: async (
|
||||
id: string,
|
||||
payload: Partial<CreateBookingPayload>,
|
||||
documents?: BookingDocuments,
|
||||
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
|
||||
const { data } = await client.patch(`/api/bookings/${id}`, payload);
|
||||
const formData = buildBookingFormData(payload, documents);
|
||||
const { data } = await client.patch(`/api/bookings/${id}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
@@ -101,11 +130,16 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
submit: async (id: string): Promise<Freight.IBooking> => {
|
||||
submit: async (id: string): Promise<SubmitBookingResponse> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/submit`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
id: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
|
||||
@@ -10,10 +10,10 @@ export default registerAs("app", () => ({
|
||||
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
|
||||
reconciliation: {
|
||||
/** How often the stale-intent sweep runs. */
|
||||
sweepIntervalMs: parseInt(
|
||||
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
|
||||
10,
|
||||
),
|
||||
// TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to 30s
|
||||
// here so the .env (RECONCILE_SWEEP_INTERVAL_MS) cannot override it.
|
||||
// sweepIntervalMs: parseInt(process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", 10),
|
||||
sweepIntervalMs: 30_000,
|
||||
/** An intent is "stale" when non-terminal and untouched for this long. */
|
||||
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
|
||||
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
|
||||
|
||||
@@ -13,7 +13,10 @@ export default registerAs("waafi", () => ({
|
||||
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
||||
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
|
||||
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
|
||||
currency: process.env.WAAFI_CURRENCY ?? "DJF",
|
||||
// TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
|
||||
// here so the .env (WAAFI_CURRENCY) cannot override it.
|
||||
// currency: process.env.WAAFI_CURRENCY ?? "DJF",
|
||||
currency: "USD",
|
||||
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
||||
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
|
||||
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly cacBankProvider: CacBankProvider,
|
||||
) {
|
||||
this.intervalMs =
|
||||
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
||||
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 30_000;
|
||||
this.staleAfterMs =
|
||||
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
|
||||
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;
|
||||
|
||||
@@ -265,6 +265,56 @@ export interface IConsignment extends BaseEntity {
|
||||
destinationStation: string;
|
||||
}
|
||||
|
||||
/** One station along the train's corridor (origin → milestones → destination). */
|
||||
export interface ITrackingStation {
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** A logged checkpoint as the train passes a station. */
|
||||
export interface ITrackingCheckpoint {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string | null;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing shipment tracking payload for a single booking, derived from
|
||||
* the train schedule the booking is assigned to and the live checkpoint log.
|
||||
*
|
||||
* `hasSchedule` is false when the booking has not been assigned to a train yet
|
||||
* (still pre-dispatch) — the UI shows a "not on the rails yet" state.
|
||||
*/
|
||||
export interface IBookingTracking {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
hasSchedule: boolean;
|
||||
scheduleId: string | null;
|
||||
trainNumber: string | null;
|
||||
/** Operational status of the assigned schedule (DRAFT/SCHEDULED/DISPATCHED/ARRIVED). */
|
||||
scheduleStatus: TrainScheduleStatus | null;
|
||||
direction: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** Ordered stations forming the corridor. */
|
||||
stations: ITrackingStation[];
|
||||
/** Logged checkpoints, ordered by sequence then time. */
|
||||
checkpoints: ITrackingCheckpoint[];
|
||||
/** Highest reached station sequence (−1 = not departed). */
|
||||
currentSequenceNo: number;
|
||||
actualDepartureAt: string | null;
|
||||
actualArrivalAt: string | null;
|
||||
/** Planned departure/arrival from the schedule, used as ETA hints. */
|
||||
scheduledDepartureAt: string | null;
|
||||
scheduledArrivalAt: string | null;
|
||||
}
|
||||
|
||||
export interface IYard extends BaseEntity {
|
||||
code: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user