Merge branch 'freight/feat/booking-schedule' into freight/develop

This commit is contained in:
ghost2023
2026-06-16 12:39:56 +03:00
35 changed files with 2648 additions and 963 deletions

View File

@@ -1,28 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../rule-engine/interfaces/cargo-types.repository.interface';
} from "../rule-engine/interfaces/cargo-types.repository.interface";
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../rule-engine/interfaces/container-types.repository.interface';
} from "../rule-engine/interfaces/container-types.repository.interface";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../rule-engine/interfaces/service-types.repository.interface';
} from "../rule-engine/interfaces/service-types.repository.interface";
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
import {
IYardsRepository,
YARDS_REPOSITORY,
} from '../rule-engine/interfaces/yards.repository.interface';
} from "../rule-engine/interfaces/yards.repository.interface";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
@@ -32,9 +32,9 @@ import {
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from './dto/booking-reference-data.dto';
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
@@ -79,14 +82,14 @@ export function groupContainersBySize(
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === 'other') return Number.MAX_SAFE_INTEGER;
if (key === "other") return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: 'ASC', code: 'ASC' },
order: { label: "ASC", code: "ASC" },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
]);
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
id: s.id,
name: s.serviceName,
code: s.code,
...s,
}),
),
shipping_line: shippingLines.map(

View File

@@ -8,87 +8,98 @@ import {
Patch,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import {
TrainSchedulingManage,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@ApiTags('train-scheduling')
@ApiTags("train-scheduling")
@ApiBearerAuth()
@Controller('train-scheduling')
@Controller("train-scheduling")
export class TrainSchedulingController {
constructor(
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
) {}
) { }
@Get('global-rules')
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
getGlobalRules() {
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
}
@Patch('global-rules')
@Patch("global-rules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
}
@Get('eligible-bookings')
@Get("eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
@ApiOperation({ summary: "List eligible bookings (container and/or bulk)" })
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
return this.trainSchedulingService.getEligibleBookings(query);
}
@Get('batch-board')
@Get("batch-board")
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
@ApiOperation({
summary: "Batch monitoring board: schedules with bookings grouped by state",
})
getBatchBoard() {
return this.bookingBatchService.getBatchBoard();
}
@Get('batch-board/:scheduleId')
@Get("batch-board/:scheduleId")
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
@ApiOperation({
summary: "Batch board detail for one schedule with EAT 3h windows",
})
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
}
@Get('available-locomotives')
@Get("available-locomotives")
@TrainSchedulingView()
@ApiOperation({
summary: 'List AVAILABLE locomotives at the route origin yard',
summary: "List AVAILABLE locomotives at the route origin yard",
})
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
return this.trainSchedulingService.getAvailableLocomotivesForRoute(
query.routeId,
);
}
@Get('bookable-schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
@Get("bookable-schedules")
// @TrainSchedulingView()
@ApiOperation({
summary: "OPEN same-route schedules a new booking can target",
})
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
return this.trainSchedulingService.getBookableSchedules(
query.originYardId,
@@ -96,282 +107,323 @@ export class TrainSchedulingController {
);
}
@Get('container/eligible-bookings')
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
@ApiOperation({ summary: "List eligible container bookings" })
getEligibleContainerBookings(
@Query() query: GetEligibleContainerBookingsDto,
) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Get('bulk/eligible-bookings')
@Get("bulk/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bulk bookings' })
@ApiOperation({ summary: "List eligible bulk bookings" })
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
return this.trainSchedulingService.getEligibleBulkBookings(query);
}
@Post('preview')
@Post("preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
@ApiOperation({ summary: "Preview a mixed-capable train schedule" })
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
return this.trainSchedulingService.previewTrainSchedule(dto);
}
@Post('container/preview')
@Post("container/preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a container train schedule' })
@ApiOperation({ summary: "Preview a container train schedule" })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('bulk/preview')
@Post("bulk/preview")
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a bulk train schedule' })
@ApiOperation({ summary: "Preview a bulk train schedule" })
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
}
@Post('container/schedules')
@Post("container/schedules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a container train schedule' })
@ApiOperation({ summary: "Create a container train schedule" })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('bulk/schedules')
@Post("bulk/schedules")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a bulk train schedule' })
@ApiOperation({ summary: "Create a bulk train schedule" })
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('schedules/:id/assign-bookings')
@Post("schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
@ApiOperation({
summary: "Assign bookings to a train schedule (mixed-capable)",
})
assignBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
}
@Post('container/schedules/:id/assign-bookings')
@Post("container/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
assignContainerBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
return this.trainSchedulingService.assignBookingsToSchedule(
id,
dto,
"CONTAINER",
);
}
@Post('bulk/schedules/:id/assign-bookings')
@Post("bulk/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
assignBulkBookings(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
return this.trainSchedulingService.assignBookingsToSchedule(
id,
dto,
"BULK",
);
}
@Delete('schedules/:id/bookings/:bookingId')
@Delete("schedules/:id/bookings/:bookingId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
unassignBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
return this.trainSchedulingService.unassignBooking(
id,
bookingId,
resolveAuthUserId(user),
);
}
@Delete('schedules/:id/wagons/:trainSetWagonId')
@Delete("schedules/:id/wagons/:trainSetWagonId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
removeWagonSlot(
@Param('id', ParseUUIDPipe) id: string,
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("trainSetWagonId", ParseUUIDPipe) trainSetWagonId: string,
) {
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
return this.trainSchedulingService.removeTrainSetWagonSlot(
id,
trainSetWagonId,
);
}
@Patch('schedules/:id/container-items/:itemId')
@Patch("schedules/:id/container-items/:itemId")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
@ApiOperation({ summary: "Update a container number on a wagon slot" })
updateContainerItem(
@Param('id', ParseUUIDPipe) id: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("itemId", ParseUUIDPipe) itemId: string,
@Body() dto: UpdateContainerItemDto,
) {
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
}
@Get('schedules/:id/unassigned-bookings')
@Get("schedules/:id/unassigned-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
getUnassignedBookings(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getUnassignedBookings(id);
}
@Post('schedules/:id/assign-unassigned-booking')
@Post("schedules/:id/assign-unassigned-booking")
@TrainSchedulingManage()
@ApiOperation({
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
summary:
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
})
assignUnassignedBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AssignUnassignedBookingDto,
) {
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
return this.trainSchedulingService.assignUnassignedBookingToWagons(
id,
dto.bookingId,
);
}
@Get('schedules/:id/composition-removals')
@Get("schedules/:id/composition-removals")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get removal log for a schedule' })
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get removal log for a schedule" })
getCompositionRemovals(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getCompositionRemovals(id);
}
@Post('schedules/:id/pin-wagons')
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
return this.trainSchedulingService.pinWagons(id, dto);
}
@Post('schedules/:id/finalize')
@Post("schedules/:id/finalize")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Finalize a draft train schedule' })
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Finalize a draft train schedule" })
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post('schedules/:id/dispatch')
@Post("schedules/:id/dispatch")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Dispatch a scheduled train' })
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
}
// ---- batch / booking-window staff actions ----
@Post('schedules/:id/run-batch')
@Post("schedules/:id/run-batch")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
await this.bookingBatchService.fillSchedule(id);
return this.bookingBatchService.getBatchBoardDetail(id);
}
@Post('schedules/:id/run-allocation')
@Post("schedules/:id/run-allocation")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({
summary: "Run wagon-level allocation for all eligible linked bookings",
})
async runAllocation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingBatchService.runWagonAllocation(id);
}
@Patch('schedules/:id/booking-window')
@Patch("schedules/:id/booking-window")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Open or close a schedule booking window' })
@ApiOperation({ summary: "Open or close a schedule booking window" })
async setBookingWindow(
@Param('id', ParseUUIDPipe) id: string,
@Body('status') status: 'OPEN' | 'CLOSED',
@Param("id", ParseUUIDPipe) id: string,
@Body("status") status: "OPEN" | "CLOSED",
) {
await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN');
await this.trainSchedulingService.setBookingWindow(
id,
status === "CLOSED" ? "CLOSED" : "OPEN",
);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('bookings/:bookingId/mark-paid')
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' })
async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@ApiOperation({
summary: "Staff: mark a reserved booking paid and allocate it now",
})
async markBookingPaid(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
await this.bookingBatchService.markPaid(bookingId);
return { ok: true };
}
@Post('bookings/:bookingId/expire')
@Post("bookings/:bookingId/expire")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' })
async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})
async expireBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
await this.bookingBatchService.expireReservation(bookingId);
return { ok: true };
}
@Post('bookings/:bookingId/move-schedule')
@Post("bookings/:bookingId/move-schedule")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' })
@ApiOperation({
summary: "Re-point a booking to another OPEN same-route schedule",
})
async moveBookingSchedule(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
) {
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
return { ok: true };
}
@Get('schedules/:id/checkpoints')
@Get("schedules/:id/checkpoints")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({
summary: "Get the tracking corridor + logged checkpoints for a train",
})
getScheduleCheckpoints(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleCheckpoints(id);
}
@Post('schedules/:id/checkpoints')
@Post("schedules/:id/checkpoints")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
@ApiOperation({
summary: "Log the train passing a station (final station triggers arrival)",
})
recordCheckpoint(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RecordCheckpointDto,
) {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Post('schedules/:id/arrive')
@Post("schedules/:id/arrive")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({
summary:
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
})
arriveSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id);
}
@Get('container/schedules')
@Get("container/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })
@ApiOperation({ summary: "List container train schedules" })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('bulk/schedules')
@Get("bulk/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: 'List bulk train schedules' })
@ApiOperation({ summary: "List bulk train schedules" })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@Get("container/schedules/:id")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get container train schedule detail" })
getContainerTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get('bulk/schedules/:id')
@Get("bulk/schedules/:id")
@TrainSchedulingView()
@ApiOperation({ summary: 'Get bulk train schedule detail' })
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Get bulk train schedule detail" })
getBulkTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@Post("container/schedules/:id/cancel")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Cancel container train schedule" })
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post('bulk/schedules/:id/cancel')
@Post("bulk/schedules/:id/cancel")
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel bulk train schedule' })
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -13,6 +13,8 @@ const statusColorMap: Record<string, string> = {
FULLY_EXECUTED: "indigo",
PNR_GENERATED: "violet",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
SELECTED_FOR_BATCH: "orange",
EXPIRED: "red",
PAID: "green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { Group, Stack, Text } from "@mantine/core";
import { Timer } from "lucide-react";
import { SectionCard } from "./SectionCard";
export interface BookingPaymentCountdownCardProps {
/** ISO timestamp marking the end of the pay window. */
paymentDeadline: string;
}
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
expired: boolean;
}
function getRemaining(deadlineMs: number): Remaining {
const diff = deadlineMs - Date.now();
if (diff <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
}
const totalSeconds = Math.floor(diff / 1000);
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
expired: false,
};
}
function Segment({ value, label }: { value: number; label: string }) {
return (
<Stack gap={2} align="center" style={{ minWidth: 56 }}>
<Text fw={700} size="2rem" style={{ lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>
{String(value).padStart(2, "0")}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" lts="0.06em">
{label}
</Text>
</Stack>
);
}
/** Live countdown to the payment deadline. Ticks every second; shows an expired state past the deadline. */
export function BookingPaymentCountdownCard({ paymentDeadline }: BookingPaymentCountdownCardProps) {
const deadlineMs = new Date(paymentDeadline).getTime();
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
useEffect(() => {
setRemaining(getRemaining(deadlineMs));
const interval = setInterval(() => {
const next = getRemaining(deadlineMs);
setRemaining(next);
if (next.expired) {
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
}, [deadlineMs]);
const accent = remaining.expired ? "red" : "orange";
return (
<SectionCard
icon={Timer}
title="Payment Deadline"
subtitle={
remaining.expired
? "The pay window has closed"
: "Time remaining to complete payment"
}
accent={accent}
>
{remaining.expired ? (
<Text fw={600} c="red.7">
Expired
</Text>
) : (
<Group justify="center" gap="lg" wrap="nowrap">
<Segment value={remaining.days} label="Days" />
<Segment value={remaining.hours} label="Hours" />
<Segment value={remaining.minutes} label="Mins" />
<Segment value={remaining.seconds} label="Secs" />
</Group>
)}
</SectionCard>
);
}

View File

@@ -137,6 +137,8 @@ export interface BookingDetailView {
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
paymentDeadline?: string | null;
createdAt: string;
updatedAt: string;
company?: BookingNamedRefView;

View File

@@ -9,6 +9,7 @@ export * from "./BookingContainersCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard";
export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";

View File

@@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Payment Verification",
color: "bg-amber-50 text-amber-800 border-amber-200",
},
SELECTED_FOR_BATCH: {
label: "Selected for Batch",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
EXPIRED: {
label: "Expired",
color: "bg-red-50 text-red-700 border-red-200",
},
PAID: {
label: "Paid",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
@@ -241,6 +249,8 @@ export const BOOKING_LIST_TABS = [
"FULLY_EXECUTED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"SELECTED_FOR_BATCH",
"EXPIRED",
],
},
{
@@ -270,6 +280,8 @@ export const WORKFLOW_STAGES = [
"FULLY_EXECUTED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"SELECTED_FOR_BATCH",
"EXPIRED",
],
},
{

View File

@@ -1,21 +1,21 @@
import { useParams, useNavigate } from "react-router-dom";
import { Container, Stack, Grid } from "@mantine/core";
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import {
detailStyles,
type BookingDetailView,
BookingDetailToolbar,
BookingDetailHeader,
BookingLifecycleStepper,
BookingRouteCard,
BookingContainersCard,
BookingApprovalCard,
BookingReviewNotesCard,
BookingPaymentCard,
BookingFactsCard,
BookingContainersCard,
BookingDetailToolbar,
BookingDocumentsCard,
BookingFactsCard,
BookingLifecycleStepper,
BookingPaymentCard,
BookingPaymentCountdownCard,
BookingReviewNotesCard,
BookingRouteCard,
detailStyles,
type BookingDetailView
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
@@ -25,8 +25,9 @@ const BookingDetailPage = () => {
const booking: BookingDetailView = {
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
reference: "BKG-2026-001456",
status: "IN_TRANSIT",
status: "SELECTED_FOR_BATCH",
scheduledDate: "2026-06-15",
paymentDeadline: "2026-06-18T17:00:00Z",
totalAmount: 15750.5,
paymentCurrency: "USD",
paymentStatus: "PAID",
@@ -138,6 +139,9 @@ const BookingDetailPage = () => {
{/* RIGHT — summary sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && (
<BookingPaymentCountdownCard paymentDeadline={booking.paymentDeadline} />
)}
<BookingPaymentCard
totalAmount={booking.totalAmount}
currency={booking.paymentCurrency}

View File

@@ -38,6 +38,7 @@
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@hookform/devtools": "^4.4.0",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",

View File

@@ -75,12 +75,10 @@ function RequireAuth() {
* Only redirects on a confirmed "no company" response — never on a
* transient query error.
*/
function RequireCompany() {
function RequireCompany({path}: {path: string}) {
const { customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (customerQuery.isSuccess && !customerQuery.data)
return <Navigate to="/onboarding" replace />;
return <Outlet />;
}
@@ -175,7 +173,7 @@ const App = () => {
<Route path="/onboarding" element={<OnboardingPage />} />
</Route>
<Route element={<RequireCompany />}>
<Route element={<RequireCompany path={location.pathname} />}>
<Route
element={
<AppLayout

View File

@@ -96,4 +96,8 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
},
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
},
};

View File

@@ -1,15 +1,14 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/services/api";
import type {
LoginPayload,
LoginResponse,
OtpResponse,
SignupPayload,
SignupResponse,
OtpResponse,
} from "@/types/auth";
import type { Result } from "@/utils/result";
import { extractApiError } from "@/utils/result";
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
function setCookie(name: string, value: string, days: number) {
const expires = new Date();
@@ -32,6 +31,8 @@ const useAuth = () => {
api.auth.getMyInfo.queryOptions({
enabled: hasToken,
retry: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
}),
@@ -46,13 +47,6 @@ const useAuth = () => {
}),
);
useEffect(() => {
if (authQuery.isError) {
queryClient.clear();
localStorage.clear();
}
}, [authQuery.isError, queryClient]);
const isPending = authQuery.isPending && hasToken;
const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError;

View File

@@ -1,4 +1,12 @@
import { Box, Grid, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import {
Box,
Grid,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import {
@@ -14,7 +22,7 @@ import {
Zap,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
@@ -30,7 +38,12 @@ const cv = (token: string) => {
return `var(--mantine-color-${name}-${shade ?? "6"})`;
};
const ACTIVE_STATUSES = ["DRAFT", "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT"];
const ACTIVE_STATUSES = [
"DRAFT",
"SUBMITTED",
"PENDING_APPROVAL",
"IN_TRANSIT",
];
interface StageConfig {
stage: number;
@@ -43,7 +56,11 @@ interface StageConfig {
badgeBg: string;
badgeText: string;
badgeDot: string;
action: { label: string; kind: "dark" | "amber" | "outline"; icon?: LucideIcon };
action: {
label: string;
kind: "dark" | "amber" | "outline";
icon?: LucideIcon;
};
}
const STATUS_CONFIG: Record<string, StageConfig> = {
@@ -73,19 +90,162 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CHANGES_REQUESTED: {
stage: 1,
icon: FilePen,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Changes requested · please update",
step: "edr-accent",
badgeLabel: "Revise",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Update", kind: "dark" },
},
PENDING_APPROVAL: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Pending internal approval",
step: "edr-blue-dot",
badgeLabel: "Pending",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
APPROVED_PENDING_SIGNATURE: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Approved · awaiting signature",
step: "edr-blue-dot",
badgeLabel: "For Signature",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review", kind: "outline" },
},
APPROVED: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Quote approved · ready to sign",
step: "edr-green.5",
badgeLabel: "Approved",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
CONTRACT_READY: {
stage: 2,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Contract ready · awaiting signature",
step: "edr-green.5",
badgeLabel: "Contract Ready",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Review", kind: "outline" },
},
SIGNED_CUSTOMER: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Signed by customer · internal processing",
step: "edr-green.5",
badgeLabel: "Signed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
FULLY_EXECUTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Fully executed · generating PNR",
step: "edr-green.5",
badgeLabel: "Executed",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "PNR generated · awaiting payment verification",
step: "edr-blue-dot",
badgeLabel: "PNR Ready",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
PAYMENT_VERIFICATION_IN_PROGRESS: {
stage: 2,
icon: Clock3,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Verifying payment · please wait",
step: "edr-accent",
badgeLabel: "Verifying",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "View", kind: "outline" },
},
SELECTED_FOR_BATCH: {
stage: 2,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Quote ready · awaiting payment",
hint: "Selected for batch · payment due within 1 hour",
step: "edr-accent",
badgeLabel: "Awaiting Payment",
badgeLabel: "Pay Now",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
},
EXPIRED: {
stage: 1,
icon: Clock3,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Payment window expired · contact support",
step: "edr-red",
badgeLabel: "Expired",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PAID: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Payment received · awaiting dispatch",
step: "edr-green.5",
badgeLabel: "Paid",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
IN_TRANSIT: {
stage: 3,
icon: Truck,
@@ -100,6 +260,19 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
action: { label: "Track", kind: "outline", icon: MapPin },
},
COMPLETED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Completed · awaiting delivery",
step: "edr-green.5",
badgeLabel: "Completed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View", kind: "outline" },
},
DELIVERED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
@@ -130,12 +303,38 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
icon: FilePen,
iconColor: "edr-red",
tile: "edr-red-soft",
hint: "Rejected",
hint: "Rejected · contact support",
step: "edr-red",
badgeLabel: "Rejected",
badgeBg: "edr-red-soft",
badgeText: "edr-red",
badgeDot: "edr-red",
action: { label: "Contact", kind: "outline" },
},
PENDING_CONSOLIDATION: {
stage: 3,
icon: Clock3,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Awaiting consolidation",
step: "edr-blue-dot",
badgeLabel: "Consolidating",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CONSOLIDATED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Consolidated · ready for dispatch",
step: "edr-green.5",
badgeLabel: "Consolidated",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
};
@@ -146,7 +345,10 @@ const ACTION_PROPS: Record<string, { bg: string; c: string; bd?: string }> = {
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
};
const INVOICE_BADGE: Record<InvoiceStatus, { label: string; bg: string; text: string }> = {
const INVOICE_BADGE: Record<
InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
@@ -157,108 +359,181 @@ const INVOICE_BADGE: Record<InvoiceStatus, { label: string; bg: string; text: st
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
type Tab = "all" | "needs" | "completed";
const NEEDS_ACTION = ["DRAFT", "PENDING_APPROVAL"];
export default function MyPortalPage() {
const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>("all");
const bookingsQuery = useQuery(
api.bookings.list.queryOptions({ input: { sortBy: "createdAt", sortOrder: "DESC" } }),
api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" },
}),
);
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status));
const activeBookings = allBookings.filter((b) =>
ACTIVE_STATUSES.includes(b.status),
);
const visibleBookings = allBookings
const visibleBookings = allBookings;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0);
const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12;
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
0,
);
const deliveredCount =
myShipments.filter((s) => s.status === "Delivered").length || 12;
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const companyName = (customer as any)?.companyName ?? displayName;
const hour = new Date().getHours();
const greeting = hour < 12 ? "Good morning," : hour < 18 ? "Good afternoon," : "Good evening,";
const greeting =
hour < 12
? "Good morning,"
: hour < 18
? "Good afternoon,"
: "Good evening,";
const recentInvoices = myInvoices.slice(0, 3);
const maxVolume = Math.max(...VOLUME_DATA);
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
{/* ── Hello Row ─────────────────────────────────────────────────────── */}
<Group
justify="space-between"
align="center"
gap="md"
className="!flex-col !items-stretch md:!flex-row md:!items-center"
>
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">{greeting}</Text>
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
{/* Book a shipment CTA */}
<Group
component={Link as any}
to="/bookings/new"
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:!w-[240px] rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Link to="/bookings/new">
<Group
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Truck size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>Book a shipment</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Book a shipment
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Link>
</Group>
{!customer&& (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Text fz={15} fw={700} c="edr-text" mb={6}>
Setup your Company Profile
</Text>
<Text fz={13} c="edr-muted" mb={12}>
Complete your company information to unlock all features and start booking shipments.
</Text>
<Link to="/onboarding" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
Complete Setup
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
)}
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<StatKpi icon={Truck} label="Active Shipments" value={activeBookings.length.toString()} delta="+2 this week" deltaColor="edr-green.7" />
<StatKpi icon={Clock3} label="Awaiting Payment" value={outstandingInvoices.length.toString()} delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`} deltaColor="edr-amber-text" divider />
<StatKpi icon={CheckCircle2} label="Delivered (May)" value={deliveredCount.toString()} delta="96% on-time" deltaColor="edr-muted" divider />
<StatKpi icon={Wallet} label="Spend YTD" value="ETB 1.24M" delta="+16% YoY" deltaColor="edr-green.7" divider />
<StatKpi
icon={Truck}
label="Active Shipments"
value={activeBookings.length.toString()}
delta="+2 this week"
deltaColor="edr-green.7"
/>
<StatKpi
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoices.length.toString()}
delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (May)"
value={deliveredCount.toString()}
delta="96% on-time"
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value="ETB 1.24M"
delta="+16% YoY"
deltaColor="edr-green.7"
divider
/>
</SimpleGrid>
</Box>
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
<Grid align="stretch">
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box>
<Text fz={19} fw={800} c="edr-text">My Shipments</Text>
<Text fz={13} c="edr-muted">From draft to delivery every booking in one place</Text>
<Text fz={19} fw={800} c="edr-text">
My Shipments
</Text>
<Text fz={13} c="edr-muted">
From draft to delivery every booking in one place
</Text>
</Box>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={6}>{[1, 2, 3, 4].map((i) => <Skeleton key={i} height={64} radius="md" />)}</Stack>
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={64} radius="md" />
))}
</Stack>
) : visibleBookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<Stack gap={0}>
{visibleBookings.map((booking, i) => (
<BookingRow key={booking.id} booking={booking} last={i === visibleBookings.length - 1} onClick={() => navigate(`/bookings/${booking.id}`)} />
<BookingRow
key={booking.id}
booking={booking}
last={i === visibleBookings.length - 1}
onClick={() => navigate(`/bookings/${booking.id}`)}
/>
))}
</Stack>
)}
@@ -269,22 +544,48 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Invoices</Text>
<Group component={Link as any} to="/billing" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
<Text fz={17} fw={700} c="edr-text">
Invoices
</Text>
<Link to="/billing">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{/* Outstanding card */}
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
<Text fz={12} fw={600} c="edr-amber-text">Outstanding balance</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">{formatCurrency(totalOutstanding || 377500, "ETB")}</Text>
<Group justify="space-between" align="center" mt={8} wrap="nowrap">
<Text fz={12} c="edr-amber-text">{outstandingInvoices.length || 2} invoices unpaid</Text>
<Group gap={5} align="center" px={14} py={8} bg="edr-accent" className="cursor-pointer rounded-[9px]">
<Text fz={12} fw={600} c="edr-amber-text">
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">Pay all</Text>
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
</Box>
@@ -302,26 +603,53 @@ export default function MyPortalPage() {
: invoice.status === "Overdue"
? "Overdue 3 days"
: `Due ${invoice.dueDate}`;
const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted");
const DueIcon =
invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
return (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Box>
<Text fz={13} fw={700} c="edr-text">{invoice.number}</Text>
<Text fz={11} c="edr-muted">{invoice.bookingReference}</Text>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">{formatCurrency(invoice.amount, invoice.currency)}</Text>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group justify="space-between" align="center" wrap="nowrap">
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">{dueText}</Text>
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
<Box bg={badge.bg} px={10} py={4} className="rounded-full">
<Text fz={11} fw={700} c={badge.text}>{badge.label}</Text>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
@@ -335,27 +663,40 @@ export default function MyPortalPage() {
</Grid>
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
<Grid gutter={20} align="stretch">
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">Freight Volume</Text>
<Text fz={17} fw={700} c="edr-text">
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
<Text fz={26} fw={800} c="edr-text">4,180 t</Text>
<Text fz={13} c="edr-muted">ETB 1.24M</Text>
<Text fz={12} fw={700} c="edr-green.7">+16% YTD</Text>
<Text fz={26} fw={800} c="edr-text">
4,180 t
</Text>
<Text fz={13} c="edr-muted">
ETB 1.24M
</Text>
<Text fz={12} fw={700} c="edr-green.7">
+16% YTD
</Text>
</Group>
<Group align="flex-end" gap={10} className="h-[110px]">
{VOLUME_DATA.map((val, i) => {
const isLast = i === VOLUME_DATA.length - 1;
return (
<Box key={i} className="flex flex-1 flex-col items-center gap-2">
<Box
key={i}
className="flex flex-1 flex-col items-center gap-2"
>
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((val / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">{MONTHS[i]}</Text>
<Text fz={11} c="edr-muted">
{MONTHS[i]}
</Text>
</Box>
);
})}
@@ -366,21 +707,35 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, md: 7 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Recent Activity</Text>
<Group component={Link as any} to="/bookings" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
<Text fz={17} fw={700} c="edr-text">
Recent Activity
</Text>
<Link to="/bookings">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={10}>{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} height={44} radius="md" />)}</Stack>
<Stack gap={10}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : allBookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{allBookings.slice(0, 6).map((booking) => (
<ActivityRow key={booking.id} booking={booking} onClick={() => navigate(`/bookings/${booking.id}`)} />
<ActivityRow
key={booking.id}
booking={booking}
onClick={() => navigate(`/bookings/${booking.id}`)}
/>
))}
</Stack>
)}
@@ -403,7 +758,10 @@ function Card({
padding?: number;
}) {
return (
<Box p={padding} className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}>
<Box
p={padding}
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
>
{children}
</Box>
);
@@ -425,14 +783,25 @@ function StatKpi({
divider?: boolean;
}) {
return (
<Box px={4} className={divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined}>
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>{label}</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>{value}</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>{delta}</Text>
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
</Group>
</Box>
);
@@ -446,9 +815,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
const active = i === stage;
const size = active ? 12 : done ? 9 : 8;
return (
<Group key={i} gap={0} align="center" wrap="nowrap" className={i < 4 ? "flex-1" : undefined}>
<Box w={size} h={size} bg={done || active ? color : "edr-step-idle"} className="shrink-0 rounded-full" />
{i < 4 && <Box h={3} bg={i < stage ? color : "edr-conn-idle"} className="flex-1 rounded-full" />}
<Group
key={i}
gap={0}
align="center"
wrap="nowrap"
className={i < 4 ? "flex-1" : undefined}
>
<Box
w={size}
h={size}
bg={done || active ? color : "edr-step-idle"}
className="shrink-0 rounded-full"
/>
{i < 4 && (
<Box
h={3}
bg={i < stage ? color : "edr-conn-idle"}
className="flex-1 rounded-full"
/>
)}
</Group>
);
})}
@@ -456,43 +842,97 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
);
}
function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; onClick: () => void }) {
function BookingRow({
booking,
last,
onClick,
}: {
booking: any;
last: boolean;
onClick: () => void;
}) {
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
const commodity =
(typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ??
(typeof booking.cargoType === "string"
? booking.cargoType
: booking.cargoType?.name) ??
booking.commodity ??
"Freight";
return (
<Box className={last ? undefined : "border-b border-edr-divider"}>
<Group gap={16} align="center" wrap="nowrap" py={14} px={4} className="cursor-pointer" onClick={onClick}>
<Box w={46} h={46} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-xl">
<Group
gap={16}
align="center"
wrap="nowrap"
py={14}
px={4}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={46}
h={46}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-xl"
>
<Icon size={22} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
<Text fz={15} fw={700} c="edr-text" truncate>{booking.reference}</Text>
<Text fz={12} c="edr-muted" truncate>{commodity} · {origin} {dest}</Text>
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>
</Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>{cfg.hint}</Text>
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
{cfg.hint}
</Text>
<Stepper stage={cfg.stage} color={cfg.step} />
</Box>
<Stack gap={9} align="flex-end" className="shrink-0">
<Group gap={6} align="center" px={11} py={5} bg={cfg.badgeBg} className="rounded-full">
<Group
gap={6}
align="center"
px={11}
py={5}
bg={cfg.badgeBg}
className="rounded-full"
>
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<Text fz={11} fw={700} c={cfg.badgeText}>{cfg.badgeLabel}</Text>
<Text fz={11} fw={700} c={cfg.badgeText}>
{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
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>
@@ -500,7 +940,13 @@ function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; o
);
}
function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }) {
function ActivityRow({
booking,
onClick,
}: {
booking: any;
onClick: () => void;
}) {
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const Icon = cfg.icon;
const verb =
@@ -514,26 +960,49 @@ function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }
? "submitted for review"
: "created";
return (
<Group gap={12} align="center" wrap="nowrap" py={9} className="cursor-pointer" onClick={onClick}>
<Box w={36} h={36} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-[10px]">
<Group
gap={12}
align="center"
wrap="nowrap"
py={9}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={36}
h={36}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-[10px]"
>
<Icon size={17} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1">
<Text fz={13} fw={600} c="edr-text" truncate>Booking {booking.reference} {verb}</Text>
<Text fz={13} fw={600} c="edr-text" truncate>
Booking {booking.reference} {verb}
</Text>
<Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">{format(new Date(booking.createdAt), "MMM d")}</Text>
<Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")}
</Text>
</Group>
);
}
function EmptyState({ message }: { message: string }) {
return (
<Box py="xl" className="rounded-xl border border-dashed border-edr-border text-center">
<Text size="sm" c="edr-muted">{message}</Text>
<Box
py="xl"
className="rounded-xl border border-dashed border-edr-border text-center"
>
<Text size="sm" c="edr-muted">
{message}
</Text>
</Box>
);
}

View File

@@ -1,12 +1,18 @@
import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
Box,
Group,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
ArrowUpFromLine,
Building2,
ChevronRight,
Ship,
Truck,
} from "lucide-react";
import { useState } from "react";
@@ -27,37 +33,37 @@ const USER_TYPE_CARDS: {
description: string;
icon: React.ReactNode;
}[] = [
{
id: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
{
id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)",
description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship size={22} />,
},
{
id: "transporter",
label: "Transporter",
description: "Trucking company providing first/last-mile services.",
icon: <Truck size={22} />,
},
];
{
id: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
// {
// id: "freight-forwarder-dj",
// label: "FF Agent (Djibouti)",
// description: "Djibouti-based agent coordinating cross-border logistics.",
// icon: <Ship size={22} />,
// },
// {
// id: "transporter",
// label: "Transporter",
// description: "Trucking company providing first/last-mile services.",
// icon: <Truck size={22} />,
// },
];
const USER_TYPE_LEFT_MAP: Record<
OnboardingUserType,
@@ -105,7 +111,12 @@ const PREFLIGHT_LEFT = {
"Freight Forwarders (Ethiopia & Djibouti)",
"Transporters & Fleet Operators",
],
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
};
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
@@ -120,7 +131,9 @@ export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer",
@@ -131,7 +144,8 @@ export default function OnboardingPage() {
};
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload),
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: async (data) => {
const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
@@ -139,14 +153,19 @@ export default function OnboardingPage() {
if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles);
}
await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() });
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
if (!user) return null;
const handleSubmit = (payload: CreateCompanyPayload) => {
const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] };
const enriched: CreateCompanyPayload = {
...payload,
companyType: COMPANY_TYPE_MAP[userType!],
};
createCompanyMutation.mutate(enriched);
};
@@ -209,11 +228,28 @@ export default function OnboardingPage() {
...leftConfig,
features:
userType === "transporter"
? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"]
? [
"Vehicle & fleet registration",
"TIN & FAN verification",
"First-mile / Last-mile eligibility",
]
: userType === "freight-forwarder-dj"
? ["Company details", "Representative information", "Cross-border operations"]
: ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"],
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
? [
"Company details",
"Representative information",
"Cross-border operations",
]
: [
"Company registration details",
"Contact and management personnel",
"Power of Attorney (optional)",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
};
return (

View File

@@ -71,10 +71,12 @@ export function DraftBookingView({
).length;
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,
});
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}),
);
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
const uploadMutation = useMutation({

View File

@@ -12,6 +12,7 @@ import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { CancelledBanner } from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
@@ -32,14 +33,17 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const pricing = booking.pricingBreakdown;
const canPay =
status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID";
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
return (
<PageShell>
<PageHeader
booking={booking}
actions={
canPay && (
canPay &&
!showCountdown && (
<HeaderButton
green
icon={<CreditCard size={16} />}
@@ -70,6 +74,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
/>
) : (
<StatusHero booking={booking} />
)}
@@ -114,6 +125,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
right={
<>
{showCountdown && (
<PaymentDeadlineCard
paymentDeadline={booking.paymentDeadline!}
onPay={() => payMutation.mutate()}
paying={payMutation.isPending}
/>
)}
<PaymentCard booking={booking} pricing={pricing} />
<ScheduleCard
booking={booking}

View File

@@ -0,0 +1,144 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { CreditCard, Timer } from "lucide-react";
import { useEffect, useState } from "react";
import { CardTitle, SectionCard } from "./layout";
interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
expired: boolean;
}
function getRemaining(deadlineMs: number): Remaining {
const diff = deadlineMs - Date.now();
if (diff <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
}
const totalSeconds = Math.floor(diff / 1000);
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
expired: false,
};
}
function Segment({ value, label }: { value: number; label: string }) {
return (
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
<Text
fz="28px"
fw={800}
c="#10202F"
lh={1}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{String(value).padStart(2, "0")}
</Text>
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
{label}
</Text>
</Stack>
);
}
export function PaymentDeadlineCard({
paymentDeadline,
onPay,
paying,
}: {
/** ISO timestamp marking the end of the pay window. */
paymentDeadline: string;
onPay?: () => void;
paying?: boolean;
}) {
const deadlineMs = new Date(paymentDeadline).getTime();
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
useEffect(() => {
setRemaining(getRemaining(deadlineMs));
const interval = setInterval(() => {
const next = getRemaining(deadlineMs);
setRemaining(next);
if (next.expired) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
}, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: accentBg,
color: accentFg,
}}
>
<Timer size={13} />
{remaining.expired ? "Expired" : "Pay window open"}
</Group>
</Group>
{remaining.expired ? (
<Text mt={14} fz="13.5px" c="#6B7C8E">
The payment window has closed. Move this booking to another schedule or
contact support.
</Text>
) : (
<>
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
<Segment value={remaining.days} label="Days" />
<Segment value={remaining.hours} label="Hrs" />
<Segment value={remaining.minutes} label="Min" />
<Segment value={remaining.seconds} label="Sec" />
</Group>
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
Complete payment before the window closes to secure your slot.
</Text>
{onPay && (
<Button
fullWidth
mt={16}
radius={10}
color="edr-green"
leftSection={<CreditCard size={17} />}
onClick={onPay}
loading={paying}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
>
Pay now
</Button>
)}
</>
)}
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
<Text mt={12} fz="12px" c="#9AA8B5">
Deadline:{" "}
{new Date(paymentDeadline).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</SectionCard>
);
}

View File

@@ -32,6 +32,8 @@ export const PROGRESS_STAGES = [
label: "In Transit",
icon: Train,
statuses: [
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
@@ -99,6 +101,18 @@ export const STATUS_MAP: Record<
description: "Signed by all parties. You can now proceed to payment.",
stage: 2,
},
SELECTED_FOR_BATCH: {
title: "Selected for a train — payment due",
description:
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
stage: 3,
},
EXPIRED: {
title: "Pay window expired",
description:
"The payment window was missed. You can move this booking to another schedule or cancel it.",
stage: 3,
},
PNR_GENERATED: {
title: "Payment reference generated",
description:

View File

@@ -7,6 +7,7 @@ import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
STEPS,
@@ -22,18 +23,58 @@ import {
Step2ServiceType,
Step4Route,
Step5CargoDetails,
StepDocuments,
Step8Review,
StepDocuments,
StepScheduling,
} from "./new-booking-form/steps";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const auth = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(),
);
if (!auth.isPending && !auth.company) {
return (
<Box
style={{
padding: "28px",
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<Alert
color="orange"
icon={<AlertCircle size={20} />}
radius="md"
style={{ maxWidth: "500px" }}
mb="lg"
>
<Text size="lg" fw={600} mb="md">
Complete Your Company Setup
</Text>
<Text size="sm" mb="md">
You need to complete your company onboarding before you can create
bookings. Please follow the onboarding process to get started.
</Text>
<Button
color="orange"
onClick={() => navigate("/onboarding")}
mt="md"
>
Go to Onboarding
</Button>
</Alert>
</Box>
);
}
const createMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
@@ -70,7 +111,15 @@ export default function NewBookingPage() {
const destinationYard = form.watch("destinationYard");
const direction = useMemo(
() => getRouteDirection(originYard, destinationYard),
() =>{
const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find((y) => y.id === destinationYard);
const route = getRouteDirection(
origin,destination
)
return route
},
[originYard, destinationYard],
);
@@ -100,30 +149,13 @@ export default function NewBookingPage() {
: Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findServiceTypeId = (): string => {
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
};
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findContainerCargoTypeId = (): string => {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
return group?.id ?? "";
};
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
@@ -132,47 +164,42 @@ export default function NewBookingPage() {
return "";
};
const selectedChild =
data.cargoType !== "container" && data.bulkCommoditytype
? cargoTree
.find((g) => g.code.toLowerCase() === data.freightType)
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypePath = data.cargoTypePath ?? [];
const childId = cargoTypePath[1];
const bulkChild = cargoTree
.flatMap((g) => g.children ?? [])
.find((c) => c.id === childId);
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: (selectedChild?.id ?? "");
data.cargoType === "bulk" ? childId : undefined;
const cargoFreeText =
data.cargoType === "container"
? undefined
: selectedChild?.show_free_text_box
? data.bulkCommoditytype
: undefined;
const cargoFreeText = bulkChild?.show_free_text_box
? data.cargoFreeText
: undefined;
const serviceType = referenceData?.service.find(
(s) => s.id === data.serviceTypeId,
)!;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString().slice(0, 10),
scheduledDate: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: findServiceTypeId(),
serviceTypeId: data.serviceTypeId,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
tradeDirection:
direction === "export"
? "EXPORT"
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
paymentCurrency: "USD",
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
@@ -193,10 +220,10 @@ export default function NewBookingPage() {
...(data.contractType === "renewal" && data.previousContractRef
? { pnrCode: data.previousContractRef }
: {}),
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
...(serviceType.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
: {}),
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
...(serviceType.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
@@ -248,67 +275,61 @@ export default function NewBookingPage() {
Back to Bookings
</Button>
</Group>
<form
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
>
{/* Step indicator */}
<Box>
<Box className="mx-auto max-w-5xl" style={{ paddingInline: "16px" }}>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
</Box>
{/* Step content */}
<Box flex={1}>
<Box className="mx-auto max-w-5xl" style={{ padding: "32px 24px" }}>
{createMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</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>
)}
{createMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</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>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && <StepDocuments form={form} />}
{step === 6 && (
<Step8Review
form={form}
setStep={setStep}
direction={direction}
/>
)}
</Box>
{step === 1 && <Step1ContractType form={form} referenceData={referenceData} />}
{step === 2 && (
<Step2ServiceType referenceData={referenceData} form={form} />
)}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review form={form} setStep={setStep} direction={direction!} />
)}
</Box>
{/* Navigation footer */}
@@ -364,6 +385,7 @@ export default function NewBookingPage() {
</Group>
</Box>
</form>
{/* <DevTool control={form.control} /> */}
</Box>
);
}

View File

@@ -2,15 +2,6 @@ import type { Freight } from "@edr/types";
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
export const ETHIOPIA_STATIONS = new Set<string>([
"Addis Ababa",
"Adama",
"Mojo",
"Awash",
"Mieso",
"Dire Dawa",
]);
export const MOCK_VALID_CONTRACTS = [
"EDR-2024-10001",
"EDR-2024-10002",
@@ -23,8 +14,9 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Documents", short: "Documents" },
{ id: 6, label: "Review & Submit", short: "Submit" },
{ id: 5, label: "Shipment Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
] as const;
/**
@@ -82,7 +74,7 @@ export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
serviceTypeId: z.string("Select a service type."),
firstMile: z
.object({
enabled: z.boolean().default(false),
@@ -108,10 +100,12 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.string(), // parent group
bulkCommoditytype: z.string(),
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
containers: z.array(
@@ -163,19 +157,6 @@ export const bookingFormSchema = z
path: ["destinationYard"],
},
)
.refine((data) => !(data.cargoType === "bulk" && !data.freightType), {
message: "Select a freight type.",
path: ["freightType"],
})
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType &&
!data.bulkCommoditytype
),
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
)
.refine(
(data) => {
if (data.cargoType !== "bulk") return true;
@@ -195,6 +176,21 @@ export const bookingFormSchema = z
path: ["termsAccepted"],
})
.superRefine((data, ctx) => {
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a freight type.",
});
} else if (!data.cargoTypePath[1]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a commodity.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -235,8 +231,11 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
originYard: "",
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
bulkCommoditytype: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
@@ -249,7 +248,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceType",
"serviceTypeId",
"firstMile",
"lastMile",
"equipmentReturn",
@@ -265,17 +264,15 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
4: [
"cargoType",
"cargoWeight",
"freightType",
"bulkCommoditytype",
"cargoTypePath",
"containers",
"consolidationEnabled",
],
5: ["documents"],
6: ["notes", "termsAccepted"],
5: ["scheduledDate", "trainScheduleId"],
6: ["documents"],
7: ["notes", "termsAccepted"],
};
export type RouteDirection = "import" | "export" | "domestic" | null;
export interface ContainerConfig {
type: "20ft" | "40ft";
containerType: string;
@@ -287,64 +284,32 @@ export interface WagonConfig {
type: "20ft" | "40ft";
}
export interface WagonCalcResult {
totalWagons: number;
hasOddUnit: boolean;
sharedWagons: number;
wagonLayout: WagonConfig[];
ft40Wagons: number;
ft20Wagons: number;
}
export function genContractId(): string {
const yr = new Date().getFullYear();
const n = Math.floor(10000 + Math.random() * 90000);
return `EDR-DRAFT-${yr}-${n}`;
}
export function getRouteDirection(
origin: string,
dest: string,
): RouteDirection {
origin: Freight.BookingReferenceYard | null | undefined,
dest: Freight.BookingReferenceYard | null | undefined,
): Freight.ScheduleTradeDirection | null {
if (!origin || !dest) return null;
const oLocation = getStationLocation(origin);
const dLocation = getStationLocation(dest);
if (oLocation === "inside" && dLocation === "outside") return "export";
if (oLocation === "outside" && dLocation === "inside") return "import";
if (oLocation === "inside" && dLocation === "inside") return "domestic";
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
return "DOMESTIC";
}
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "IMPORT";
}
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
return "EXPORT";
}
const oEth = ETHIOPIA_STATIONS.has(origin);
const dEth = ETHIOPIA_STATIONS.has(dest);
if (oEth && !dEth) return "export";
if (!oEth && dEth) return "import";
if (oEth && dEth) return "domestic";
return null;
}
function getStationLocation(value: string): "inside" | "outside" | null {
const normalized = value.trim().toLowerCase();
if (normalized.startsWith("inside")) return "inside";
if (normalized.startsWith("outside")) return "outside";
return null;
}
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
const Ft40Wagons = containers
.filter((c) => c.type === "40ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
export function calcWagons(containers: ContainerConfig[]) {
const Ft20Wagons = containers
.filter((c) => c.type === "20ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
const wagonLayout: WagonConfig[] = [];
let hasOddUnit = Ft20Wagons % 2 === 1;
let sharedWagons = Math.floor(Ft20Wagons / 2);
return {
totalWagons: sharedWagons + Ft40Wagons,
hasOddUnit,
sharedWagons,
ft40Wagons: Ft40Wagons,
ft20Wagons: Ft20Wagons,
wagonLayout,
};
}

View File

@@ -1,7 +1,8 @@
import { Alert, Combobox, Input, InputBase, 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 { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react";
import { Alert, Select, Text, Title } from "@mantine/core";
import type { BookingFormInputValues } from "./schema";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
@@ -70,7 +71,7 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
{children}
</Text>
);
@@ -120,8 +121,96 @@ export function SelectField({
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={error?.message}
radius="md"
allowDeselect={false}
/>
);
}
interface AsyncComboboxOption {
value: string;
label: string;
}
export function AsyncComboboxField({
field,
error,
label,
placeholder,
options,
isLoading,
searchQuery,
onSearchChange,
onSelect,
disabled,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
label: string;
placeholder: string;
options: AsyncComboboxOption[];
isLoading?: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (value: string) => void;
disabled?: boolean;
}) {
const combobox = useCombobox();
const selectedLabel = useMemo(() => {
return options.find((opt) => opt.value === field.value)?.label || "";
}, [field.value, options]);
const handleSelectOption = (val: string) => {
onSelect(val);
combobox.closeDropdown();
};
return (
<Input.Wrapper label={label} error={error?.message}>
<Combobox store={combobox} disabled={disabled}>
<Combobox.Target>
<InputBase
placeholder={placeholder}
disabled={disabled}
value={searchQuery || selectedLabel}
onChange={(e) => {
onSearchChange(e.currentTarget.value);
combobox.openDropdown();
}}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
field.onBlur();
combobox.closeDropdown();
if (!selectedLabel) {
onSearchChange("");
}
}}
rightSection={
isLoading ? <Loader size={14} /> : <Combobox.Chevron />
}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{isLoading ? (
<Combobox.Empty>Loading contracts...</Combobox.Empty>
) : options.length === 0 ? (
<Combobox.Empty>No contracts found</Combobox.Empty>
) : (
options.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
onClick={() => handleSelectOption(option.value)}
>
{option.label}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Input.Wrapper>
);
}

View File

@@ -5,12 +5,17 @@ import { Controller, type UseFormReturn } from "react-hook-form";
import {
BOOKING_DOCS_SETTING,
BookingFormInputValues,
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
function countAttached(documents: BookingDocuments): number {
return BOOKING_DOCS_SETTING.fields.filter((f) => {
@@ -57,7 +62,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
color: attached === total ? "#0A6F4D" : "#2E5B96",
}}
>
{attached === total ? <CheckCircle2 size={16} /> : `${attached}/${total}`}
{attached === total ? (
<CheckCircle2 size={16} />
) : (
`${attached}/${total}`
)}
</Box>
<Text size="sm" c="dimmed">
{attached === 0

View File

@@ -0,0 +1,564 @@
import {
Box,
Button,
Card,
Group,
Stack,
Text,
useMantineTheme,
} from "@mantine/core";
import { UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, BookingFormValues } from "./schema";
import {
ChevronLeft,
ChevronRight,
Check,
Train,
Route,
Package,
Calendar as CalendarIcon,
} from "lucide-react";
import type { Freight } from "@edr/types";
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
isSameMonth,
addMonths,
} from "date-fns";
interface StepSchedulingProps {
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
referenceData?: Freight.BookingReferenceData;
}
interface DayData {
day: number;
dateString: string;
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[];
hasSchedule: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
const containers = form.watch("containers");
const cargoTypePath = form.watch("cargoTypePath");
const cargoWeight = form.watch("cargoWeight");
const originName = useMemo(
() => referenceData?.yard.find((y) => y.id === originYardId)?.name ?? "—",
[referenceData, originYardId],
);
const destinationName = useMemo(
() =>
referenceData?.yard.find((y) => y.id === destinationYardId)?.name ?? "—",
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
// Group all schedules per date — multiple departures per day are allowed.
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
// match the format used by the calendar day keys.
const schedulesByDate = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem[]>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
const dateKey = s.scheduleDate.slice(0, 10);
const existing = map.get(dateKey) ?? [];
map.set(dateKey, [...existing, s]);
}
}
return map;
}, [bookableSchedules]);
const selectedSchedule = useMemo(
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
[bookableSchedules, selectedScheduleId],
);
const availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
schedules,
hasSchedule: schedules.length > 0,
};
});
}, [currentDate, schedulesByDate, selectedDate]);
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
if (cargoType === "container") {
const parts = (containers ?? [])
.filter((c) => Number(c.qty) > 0)
.map((c) => `${c.qty} × ${c.type}`);
return parts.length > 0 ? `Container · ${parts.join(", ")}` : "Container";
}
const childId = cargoTypePath?.[1];
const commodity = referenceData?.cargo_type
.flatMap((g) => g.children ?? [])
.find((c) => c.id === childId);
const weight = cargoWeight ? ` · ${cargoWeight} t` : "";
return commodity ? `${commodity.name}${weight}` : "Bulk freight";
}, [cargoType, containers, cargoTypePath, cargoWeight, referenceData]);
const weeksCount = Math.ceil(days.length / 7);
return (
<Group gap={24} align="flex-start" wrap="nowrap">
{/* ── Calendar Card ───────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Card p={0} style={{ overflow: "hidden" }}>
{/* Card header */}
<Group
justify="space-between"
align="center"
px={24}
py={20}
style={{
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
}}
>
<Stack gap={3}>
<Text fw={800} fz={18} c="edr-text.0">
Select a shipment date
</Text>
<Text fz={13} c="edr-muted">
Confirmed train departures · {originName} {destinationName}
</Text>
</Stack>
<Group gap={10}>
<Button
variant="default"
w={34}
h={34}
p={0}
radius="xl"
onClick={() => setCurrentDate((d) => addMonths(d, -1))}
>
<ChevronLeft size={16} />
</Button>
<Text fw={700} fz={14} c="edr-text.0" style={{ minWidth: 90, textAlign: "center" }}>
{format(currentDate, "MMMM yyyy")}
</Text>
<Button
variant="default"
w={34}
h={34}
p={0}
radius="xl"
onClick={() => setCurrentDate((d) => addMonths(d, 1))}
>
<ChevronRight size={16} />
</Button>
</Group>
</Group>
{/* Card body */}
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
</Text>
{/* Weekday headers */}
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 6,
paddingBottom: 8,
}}
>
{["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => (
<Text
key={d}
ta="center"
fz={10.5}
fw={700}
c="edr-muted"
style={{ letterSpacing: "0.06em" }}
>
{d}
</Text>
))}
</Box>
{/* Date grid */}
<Stack gap={2}>
{Array.from({ length: weeksCount }, (_, wi) => (
<Box
key={wi}
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 2,
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell
key={di}
day={d}
selectedScheduleId={selectedScheduleId}
onSelectSchedule={(scheduleId, dateString) => {
form.setValue("scheduledDate", dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
}}
/>
))}
</Box>
))}
</Stack>
</Stack>
</Card>
</Box>
{/* ── Side Panel ──────────────────────────────────────── */}
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
{/* Booking summary card */}
<Card p={0} style={{ overflow: "hidden" }}>
<Group
justify="space-between"
align="center"
px={20}
py={18}
style={{
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
}}
>
<Text fw={800} fz={15} c="edr-text.0">
Booking summary
</Text>
</Group>
<Stack gap={14} px={20} py={18}>
<SummaryRow
icon={<Route size={17} color={theme.colors["edr-muted"][0]} />}
label="ROUTE"
value={`${originName}${destinationName}`}
/>
<SummaryRow
icon={<Package size={17} color={theme.colors["edr-muted"][0]} />}
label="CARGO"
value={cargoSummary}
/>
{selectedSchedule && selectedDate && (
<Box
p={14}
style={{
borderRadius: theme.radius.lg,
backgroundColor: theme.colors["edr-soft"][0],
}}
>
<Stack gap={10}>
<Group gap={7}>
<Train size={16} color={theme.colors["edr-green"][7]} />
<Text
fz={10}
fw={700}
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DEPARTURE
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Train
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
</Text>
</Group>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Wagons available
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
</Text>
</Group>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* Help card */}
<Box
p={18}
style={{
borderRadius: theme.radius.xl,
backgroundColor: theme.colors["edr-ink"][0],
}}
>
<Stack gap={8}>
<Group gap={8} align="center">
<CalendarIcon size={18} color={theme.colors["edr-green"][5]} />
<Text fw={700} fz={14} c="white">
Need a different date?
</Text>
</Group>
<Text fz={12.5} style={{ color: "#A7B6C2", lineHeight: 1.5 }}>
Our freight desk can arrange charter departures for full-train loads.
</Text>
<Text fz={13} fw={700} c="edr-green.5" style={{ cursor: "pointer" }}>
Contact freight desk
</Text>
</Stack>
</Box>
</Stack>
</Group>
);
}
interface DayCellProps {
day: DayData;
selectedScheduleId: string;
onSelectSchedule: (scheduleId: string, dateString: string) => void;
}
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
return (
<Box
style={{
height: 92,
borderRadius: theme.radius.md,
opacity: 0.4,
overflow: "hidden",
padding: "4px 6px",
backgroundColor: "transparent",
}}
>
<Text fz={14} fw={600} c="edr-muted">
{d.day}
</Text>
</Box>
);
}
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
: d.hasSchedule
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `1.5px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
? "1px solid #E7E8E5"
: "none";
return (
<Box
style={{
height: 92,
borderRadius: theme.radius.md,
backgroundColor: cellBg,
border: cellBorder,
overflow: "hidden",
padding: "4px 6px 6px",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{/* Day number + check icon */}
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
<Text
fz={14}
fw={d.hasSchedule ? 800 : 600}
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
: d.hasSchedule
? "edr-text.0"
: "edr-muted"
}
>
{d.day}
</Text>
{d.isSelectedDate && (
<Check
size={15}
color={theme.colors["edr-green"][5]}
strokeWidth={2.5}
/>
)}
</Group>
{/* Departure chips */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
{d.schedules.slice(0, 2).map((s) => {
const isChipSelected = s.id === selectedScheduleId;
const isFull = s.remainingWagons <= 0;
const canSelect = !isFull;
return (
<Box
key={s.id}
onClick={() =>
canSelect && onSelectSchedule(s.id, d.dateString)
}
style={{
display: "flex",
alignItems: "center",
gap: 4,
borderRadius: 7,
padding: "4px 6px",
cursor: canSelect ? "pointer" : "default",
backgroundColor: isChipSelected
? theme.colors["edr-green"][5]
: isFull
? theme.colors["edr-red-soft"][0]
: theme.colors["edr-soft"][0],
border: `1px solid ${
isChipSelected
? theme.colors["edr-green"][5]
: isFull
? "#EFCFCA"
: "#BFE3D4"
}`,
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: isChipSelected
? "white"
: isFull
? theme.colors["edr-red"][0]
: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={11}
fw={700}
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
truncate
c={
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
}
>
{isFull
? "Full"
: s.trainNumber
? s.trainNumber
: `${s.remainingWagons} wgn`}
</Text>
<ChevronRight
size={12}
color={
isChipSelected
? "white"
: isFull
? theme.colors["edr-muted"][0]
: theme.colors["edr-green"][7]
}
/>
</Box>
);
})}
</Stack>
)}
</Box>
);
}
function SummaryRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
const theme = useMantineTheme();
return (
<Group gap={12} align="flex-start" wrap="nowrap">
<Box
w={34}
h={34}
style={{
borderRadius: theme.radius.md,
backgroundColor: theme.colors.gray[0],
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{icon}
</Box>
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
<Text fz={10} fw={700} style={{ color: "#9AA8B5", letterSpacing: "0.08em" }}>
{label}
</Text>
<Text fz={13} fw={600} c="edr-text.0">
{value}
</Text>
</Stack>
</Group>
);
}

View File

@@ -1,23 +1,96 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { FileText, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
BookingFormInputValues,
MOCK_VALID_CONTRACTS,
type BookingFormValues,
} from "./schema";
import {
AlertBox,
AsyncComboboxField,
OptionCard,
OptionFieldError,
SelectField,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step1ContractType({ form }: { form: BookingForm }) {
interface PreviousContractOption {
value: string;
label: string;
booking: Freight.IBooking;
}
export function Step1ContractType({
form,
referenceData,
}: {
form: BookingForm;
referenceData?: Freight.BookingReferenceData;
}) {
const contractType = form.watch("contractType");
const previousContractRef = form.watch("previousContractRef");
const [searchQuery, setSearchQuery] = useState("");
const { data: bookings, isLoading, error } = useQuery(
api.bookings.list.queryOptions({
input: {
page: 1,
pageSize: 100,
sortBy: "createdAt",
sortOrder: "DESC",
}
})
);
const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if(!bookings) return []
return bookings?.items
.map((booking) => {
const origin = booking.originYard?.label || "Unknown";
const destination = booking.destinationYard?.label || "Unknown";
return {
value: booking.id,
label: `${booking.reference} - Route: ${origin} to ${destination}`,
booking,
};
})
.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [bookings, searchQuery]);
const handleSelectContract = async (contractId: string) => {
const selected = contractOptions.find((opt) => opt.value === contractId);
if (!selected) return;
form.setValue("previousContractRef", contractId);
// Auto-fill from previous contract
const booking = selected.booking;
if (booking) {
form.setValue("serviceTypeId", booking.serviceTypeId);
form.setValue("cargoType", booking.freightType === "CONTAINER" ? "container" : "bulk");
form.setValue("equipmentReturn", booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return");
if (booking.isHazardous) form.setValue("isHazardous", booking.isHazardous);
// Look up shipping line name from reference data
if (booking.shippingLineId && referenceData?.shipping_line) {
const shippingLine = referenceData.shipping_line.find(
(sl) => sl.id === booking.shippingLineId,
);
if (shippingLine) {
form.setValue("shippingLine", shippingLine.name);
}
}
}
}
return (
<div className="space-y-6">
@@ -73,23 +146,32 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
</AlertBox>
)}
<Controller
name="previousContractRef"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
<AsyncComboboxField
field={field}
error={fieldState.error}
label="Previous Contract Reference Number"
placeholder="Select a contract..."
data={MOCK_VALID_CONTRACTS}
placeholder="Search by reference or route..."
options={contractOptions}
isLoading={isLoading}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelect={handleSelectContract}
/>
)}
/>
{previousContractRef && (
<AlertBox tone="success">
<strong>Contract found.</strong> Company details, route, and wagon
preferences will be pre-filled.
<strong>Contract found.</strong> Route, service type, and cargo
details will be pre-filled.
</AlertBox>
)}
</div>

View File

@@ -1,18 +1,52 @@
import { Switch, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Switch, TextInput } from "@mantine/core";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
import type { Freight } from "@edr/types";
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step2ServiceType({
form,
referenceData,
}: {
form: BookingForm;
referenceData?: Freight.BookingReferenceData;
}) {
const serviceTypeId = form.watch("serviceTypeId");
const serviceType = referenceData?.service.find(
(s) => s.id === serviceTypeId,
);
const { includesCustoms, includesFirstMile, includesLastMile } =
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldValidate: true },
);
}, [includesFirstMile]);
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldValidate: true },
);
}, [includesLastMile]);
useEffect(() => {
const prev = prevServiceType.current;
@@ -20,35 +54,12 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
if (!prev || prev === serviceType) return;
if (serviceType === "rail") {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
if (!includesCustoms)
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
} else if (serviceType === "rail_forwarding") {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
}
}, [serviceType, form]);
const showServiceSections = serviceType === "rail_forwarding";
}, [serviceTypeId, form]);
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
<div className="space-y-6">
<StepHeader
@@ -57,44 +68,29 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
/>
<Controller
name="serviceType"
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={serviceType === "rail"}
onClick={() => field.onChange("rail")}
>
<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">Rail Transport Only</p>
<p className="mt-0.5 text-xs text-gray-500">
Rail transport along the EDR corridor, with optional
first/last mile trucking.
</p>
<Badge color="indigo" variant="light" mt="xs" size="sm">
Option A
</Badge>
</OptionCard>
<OptionCard
selected={serviceType === "rail_forwarding"}
onClick={() => field.onChange("rail_forwarding")}
>
<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">Logistics</p>
<p className="mt-0.5 text-xs text-gray-500">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
</p>
<Badge color="edr-green" variant="light" mt="xs" size="sm">
Option B
</Badge>
</OptionCard>
{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>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -104,112 +100,120 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{/* First Mile */}
<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 && (
{includesFirstMile && (
<div className="p-4">
<Controller
name="firstMile.pickUpAddress"
name="firstMile.enabled"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
/>
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>
)}
/>
)}
</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"
/>
)}
/>
)}
</div>
)}
{/* Last Mile */}
<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 && (
{includesLastMile && (
<div className="p-4">
<Controller
name="lastMile.deliveryAddress"
name="lastMile.enabled"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
/>
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>
)}
/>
)}
</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"
/>
)}
/>
)}
</div>
)}
{/* Equipment Return */}
{lastMileEnabled && (
{includesLastMile && lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
@@ -228,7 +232,9 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
checked={field.value === "with_return"}
onChange={(e) => {
field.onChange(
e.currentTarget.checked ? "with_return" : "without_return",
e.currentTarget.checked
? "with_return"
: "without_return",
);
}}
color="edr-green"
@@ -240,31 +246,35 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)}
{/* Customs Clearing */}
<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>
{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>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
</div>
)}
/>
</div>
)}
</div>
)}
</div>

View File

@@ -26,7 +26,7 @@ export function Step4Route({
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.name, label: y.name }));
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
@@ -35,15 +35,40 @@ export function Step4Route({
}, [referenceData]);
const originData = useMemo(
() => yardOptions.filter((o) => o.value !== destinationYard),
() => {
return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if(!dest) return true;
const origin = referenceData?.yard.find((y) => y.id === o.value);
// can't go from Djibouti to Djibouti
if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false;
return true;
});
},
[yardOptions, destinationYard],
);
console.log({yardOptions,originYard, destinationYard})
const destData = useMemo(
() => yardOptions.filter((o) => o.value !== originYard),
() => {
return yardOptions.filter((o) => o.value !== originYard).filter((d) => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
if(!origin) return true;
const dest = referenceData?.yard.find((y) => y.id === d.value);
// can't go from Djibouti to Djibouti
// if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false;
return true;
});
},
[yardOptions, originYard],
);
const direction = getRouteDirection(originYard, destinationYard);
const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard));
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
@@ -57,7 +82,7 @@ export function Step4Route({
};
useEffect(() => {
if (direction === "domestic") {
if (direction === "DOMESTIC") {
form.setValue("shippingLine", "", { shouldDirty: true });
}
}, [direction]);
@@ -117,7 +142,7 @@ export function Step4Route({
</div>
)}
{direction && direction !== "domestic" && (
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}

View File

@@ -1,13 +1,19 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import {
ActionIcon,
Button,
Skeleton,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
calcWagons,
type BookingFormValues,
type RouteDirection,
} from "./schema";
import {
AlertBox,
@@ -18,7 +24,11 @@ import {
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step5CargoDetails({
form,
@@ -27,12 +37,14 @@ export function Step5CargoDetails({
isLoading,
}: {
form: BookingForm;
direction: RouteDirection;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
isLoading?: boolean;
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -40,32 +52,58 @@ export function Step5CargoDetails({
name: "containers",
});
const containerTypeOptions = useMemo(() => {
if (!referenceData?.containers) return [];
return referenceData.containers.flatMap((group) =>
group.types.map((t) => t.name),
const containerTypeOptionsBySize = useMemo(() => {
if (!referenceData?.containers) return new Map<string, string[]>();
return new Map(
referenceData.containers.map((g) => [g.size, g.types.map((t) => t.name)]),
);
}, [referenceData]);
useEffect(() => {
if (parentId) {
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
}
}, [parentId]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
return referenceData.cargo_type.filter(
(g) => g.code !== "CONTAINER" && !/container/i.test(g.name),
);
}, [referenceData]);
const freightTypeOptions = useMemo(
() =>
freightTypeGroups.map((g) => ({
value: g.id,
label: g.name,
})),
[freightTypeGroups],
);
const commodityOptions = useMemo(() => {
if (!referenceData?.cargo_type || !freightType) return [];
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
if (!referenceData?.cargo_type || !parentId) return [];
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return (
group?.children?.map((c) => ({
value: c.id,
label: c.name,
})) ?? []
);
return group?.children?.map((c) => c.name) ?? [];
}, [referenceData, freightType]);
}, [referenceData, parentId]);
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
): string | null {
if (type === "20ft" && vgm > 0) {
const limit = direction === "export" ? 25 : 20;
const limit = direction === "EXPORT" ? 25 : 20;
if (vgm > limit) {
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
}
@@ -105,7 +143,7 @@ export function Step5CargoDetails({
{/* Cargo Type */}
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<InputLabel>Cargo Type *</InputLabel>
<Controller
name="cargoType"
control={form.control}
@@ -116,7 +154,7 @@ export function Step5CargoDetails({
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", { shouldDirty: true });
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
@@ -151,7 +189,6 @@ export function Step5CargoDetails({
{/* Weight */}
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
@@ -175,51 +212,57 @@ export function Step5CargoDetails({
{/* Bulk freight type */}
{cargoType === "bulk" && (
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
<Controller
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
{freightTypeGroups.map((group) => {
const val = group.code.toLowerCase();
return (
<OptionCard
key={group.code}
selected={freightType === val}
onClick={() => {
field.onChange(val);
form.setValue("bulkCommoditytype", "", {
shouldDirty: true,
});
}}
>
<p className="font-semibold">{group.name}</p>
</OptionCard>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{freightType && commodityOptions.length > 0 && (
{freightTypeOptions.length > 0 ? (
<Controller
name="bulkCommoditytype"
name="cargoTypePath.0"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
label="Bulk Cargo Type *"
placeholder="Select freight type..."
data={freightTypeOptions}
/>
)}
/>
) : (
<Text size="sm" c="dimmed">
No freight types available.
</Text>
)}
{parentId && commodityOptions.length > 0 && (
<Controller
name="cargoTypePath.1"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label=""
placeholder="Select type *"
data={commodityOptions}
/>
)}
/>
)}
{selectedCommodity?.show_free_text_box && (
<Controller
name="cargoFreeText"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Describe cargo *"
placeholder="e.g. Charcoal, Wheat, etc."
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
)}
@@ -254,7 +297,13 @@ export function Step5CargoDetails({
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
@@ -282,7 +331,7 @@ export function Step5CargoDetails({
val: "20ft" as const,
label: "20ft Container (TEU)",
limit:
direction === "export"
direction === "EXPORT"
? "Max 25t per container"
: "Max 20t per container",
},
@@ -301,7 +350,9 @@ export function Step5CargoDetails({
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-gray-500">{ct.limit}</p>
<p className="text-xs text-gray-500">
{ct.limit}
</p>
</OptionCard>
))}
</div>
@@ -325,7 +376,10 @@ export function Step5CargoDetails({
type="button"
onClick={() =>
qtyField.onChange(
Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
@@ -334,7 +388,9 @@ export function Step5CargoDetails({
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => qtyField.onChange(e.target.value)}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur}
type="number"
min={1}
@@ -389,7 +445,11 @@ export function Step5CargoDetails({
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={containerTypeOptions}
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>

View File

@@ -1,111 +0,0 @@
import { type UseFormReturn } from "react-hook-form";
import { type BookingFormValues, type WagonCalcResult } from "./schema";
import { AlertBox, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step6WagonAllocation({
form,
wagons,
}: {
form: BookingForm;
wagons: WagonCalcResult | null;
}) {
const containers = form.watch("containers") ?? [];
const totalContainers = containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
);
const containerSummary = containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ");
return (
<div className="space-y-6">
<StepHeader
title="Wagon Allocation"
description="System-calculated wagon requirements based on your container profile."
/>
{!wagons ? (
<AlertBox tone="info">
Complete the container configuration in the previous step to see wagon
allocation.
</AlertBox>
) : (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-primary/5 p-4 text-center">
<p className="text-3xl font-bold text-primary">
{wagons.totalWagons}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Wagons Required
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{totalContainers}</p>
<p className="mt-1 text-xs text-muted-foreground">
{containerSummary || "Containers"}
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{wagons.sharedWagons}</p>
<p className="mt-1 text-xs text-muted-foreground">Shared Slots</p>
</div>
</div>
<div>
<StepLabel>Wagon Layout</StepLabel>
<div className="mt-2 flex flex-wrap gap-2">
{new Array(wagons.ft40Wagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-primary/30 bg-primary/5 text-primary `}
>
1 × 40ft
</div>
))}
{new Array(wagons.sharedWagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-amber-300 bg-amber-50 text-amber-700
`}
>
2 × 20ft
</div>
))}
{wagons.hasOddUnit && (
<div
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold border-destructive! bg-destructive/10 text-destructive `}
>
1 × 20ft
</div>
)}
</div>
</div>
{wagons.hasOddUnit && (
<>
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which <strong>may delay departure</strong>{" "}
beyond the standard lead time.
</p>
</div>
</div>
</AlertBox>
</>
)}
</>
)}
</div>
);
}

View File

@@ -5,9 +5,9 @@ import {
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormValues,
type RouteDirection,
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
@@ -18,7 +18,7 @@ export function Step8Review({
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: RouteDirection;
direction: Freight.ScheduleTradeDirection;
}) {
const values = form.watch();
const errors = form.formState.errors;

View File

@@ -2,5 +2,6 @@ export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details";
export { StepScheduling } from "./step-scheduling";
export { StepDocuments } from "./step-documents";
export { Step8Review } from "./step8-review";

View File

@@ -185,6 +185,13 @@ export const api = {
"checkPayment",
({ orderId }) => bookingsService.checkPayment(orderId),
),
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
),
},
consignments: {

View File

@@ -151,4 +151,14 @@ export const bookingsService = {
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
getBookableSchedules: async (
query: Freight.BookableSchedulesQuery = {},
): Promise<Freight.BookableScheduleItem[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES,
{ params: query },
);
return data.data;
},
};

View File

@@ -91,10 +91,10 @@ export const mantineTheme = createTheme({
fontSizes: {
xs: "12px",
sm: "13px",
md: "14px",
lg: "16px",
xl: "18px",
sm: "14px",
md: "16px",
lg: "20px",
xl: "24px",
},
lineHeights: {

View File

@@ -1,20 +1,20 @@
import type { BaseEntity } from "../common";
export * from "./file_upload_settings";
export * from "./dropdown_settings";
export * from "./file_upload_settings";
export * from "./overview";
export enum TradeDirection {
IMPORT = 'IMPORT',
EXPORT = 'EXPORT',
BOTH = 'BOTH',
IMPORT = "IMPORT",
EXPORT = "EXPORT",
BOTH = "BOTH",
}
export enum PriorityType {
USD_PAYER = 'USD_PAYER',
RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING',
GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT',
HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT',
USD_PAYER = "USD_PAYER",
RAIL_AND_FORWARDING = "RAIL_AND_FORWARDING",
GOVERNMENT_ACCOUNT = "GOVERNMENT_ACCOUNT",
HIGH_VOLUME_SHIPMENT = "HIGH_VOLUME_SHIPMENT",
}
/** Bonus applied to government bookings so they outrank commercial priority. */
@@ -26,19 +26,19 @@ export interface GovernmentBookingFields {
}
export enum ExceededAction {
WARNING_ONLY = 'WARNING_ONLY',
HARD_BLOCK = 'HARD_BLOCK',
WARNING_ONLY = "WARNING_ONLY",
HARD_BLOCK = "HARD_BLOCK",
}
export enum CalculationMethod {
PER_TON = 'PER_TON',
FLAT_FEE = 'FLAT_FEE',
PERCENTAGE = 'PERCENTAGE',
PER_TON = "PER_TON",
FLAT_FEE = "FLAT_FEE",
PERCENTAGE = "PERCENTAGE",
}
export enum FreightType {
Container = 'CONTAINER',
Bulk = 'BULK',
Container = "CONTAINER",
Bulk = "BULK",
}
export enum BookingStatus {
@@ -282,6 +282,9 @@ export interface IBooking extends BaseEntity {
totalAmount: number;
paymentStatus: PaymentStatus;
shippingLineId?: string | null;
serviceTypeId: string;
contractType: "NEW" | "RENEWAL";
previousContractId?: string | null;
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
@@ -311,6 +314,11 @@ export interface IBooking extends BaseEntity {
endDate?: string | null;
financialTerms?: string | null;
/** When the batch engine picked this booking and opened the pay window. */
selectedForBatchAt?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
paymentDeadline?: string | null;
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
versionNumber: number;
@@ -390,6 +398,18 @@ export interface BookingReferenceService {
id: string;
name: string;
code: string;
serviceName: string;
description?: string | null | undefined;
canBeBookedAlone: boolean;
includesFirstMile: boolean;
includesLastMile: boolean;
includesCustoms: boolean;
priorityBonusPoints: number;
isActive: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null | undefined;
}
export interface BookingReferenceShippingLine {
@@ -420,6 +440,39 @@ export interface BookingReferenceData {
cargo_type: BookingReferenceCargoTypeGroup[];
}
// ── Train Scheduling (bookable schedules) ──────────────────────────────────────
export interface BookableSchedulesQuery {
originYardId?: string;
destinationYardId?: string;
}
export interface BookableScheduleLocomotive {
id: string;
code: string;
name: string | null;
readiness: string | null;
}
export interface BookableScheduleItem {
id: string;
scheduleDate: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
locomotive: BookableScheduleLocomotive | null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
freightType: FreightType | "MIXED" | null;
status: TrainScheduleStatus;
bookingWindowStatus: ScheduleBookingWindow;
maxWagons: number;
remainingWagons: number;
}
// ── DTOs ───────────────────────────────────────────────────────────────────────
export interface CreateBookingContainerDto {
@@ -429,31 +482,34 @@ export interface CreateBookingContainerDto {
}
export interface CreateBookingDto {
reference?: string;
customerId?: string;
companyId?: string;
trainId?: string;
freightShapeValidation?: boolean | undefined;
reference?: string | undefined;
isGovernment?: boolean | undefined;
governmentInstitution?: string | undefined;
companyId?: string | undefined;
trainId?: string | undefined;
trainScheduleId?: string | undefined;
scheduledDate: string;
contractType: "NEW" | "RENEWAL";
previousContractId?: string;
contractType: string;
previousContractId?: string | undefined;
serviceTypeId: string;
firstMilePickupAddress?: string;
lastMileDeliveryAddress?: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
firstMilePickupAddress?: string | undefined;
lastMileDeliveryAddress?: string | undefined;
equipmentReturn: string;
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
freightType: FreightType;
cargoTypeId?: string;
cargoFreeText?: string;
shippingLineId?: string;
tradeDirection: string;
freightType: string;
cargoTypeId?: string | undefined;
cargoFreeText?: string | undefined;
shippingLineId?: string | undefined;
cargoTotalWeightVgm: number;
isHazardous?: boolean;
paymentCurrency: "ETB" | "USD";
pnrCode?: string;
startDate?: string;
endDate?: string;
financialTerms?: string;
isHazardous?: boolean | undefined;
paymentCurrency: string;
pnrCode?: string | undefined;
startDate?: string | undefined;
endDate?: string | undefined;
financialTerms?: string | undefined;
containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}

49
pnpm-lock.yaml generated
View File

@@ -368,6 +368,9 @@ importers:
'@edr/tsconfig':
specifier: workspace:*
version: link:../../../packages/config/tsconfig
'@hookform/devtools':
specifier: ^4.4.0
version: 4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
@@ -1638,6 +1641,12 @@ packages:
peerDependencies:
hono: ^4
'@hookform/devtools@4.4.0':
resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
'@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies:
@@ -8830,6 +8839,11 @@ packages:
resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==}
engines: {node: '>=18.0.0'}
little-state-machine@4.8.1:
resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
@@ -10556,6 +10570,11 @@ packages:
'@types/react':
optional: true
react-simple-animate@3.5.3:
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
peerDependencies:
react-dom: ^16.8.0 || ^17 || ^18 || ^19
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -12120,6 +12139,12 @@ packages:
'@types/react':
optional: true
use-deep-compare-effect@1.8.1:
resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==}
engines: {node: '>=10', npm: '>=6'}
peerDependencies:
react: '>=16.13'
use-isomorphic-layout-effect@1.2.1:
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
peerDependencies:
@@ -13571,6 +13596,22 @@ snapshots:
dependencies:
hono: 4.12.23
'@hookform/devtools@4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@types/lodash': 4.17.24
little-state-machine: 4.8.1(react@19.2.6)
lodash: 4.18.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-simple-animate: 3.5.3(react-dom@19.2.6(react@19.2.6))
use-deep-compare-effect: 1.8.1(react@19.2.6)
uuid: 8.3.2
transitivePeerDependencies:
- '@types/react'
- supports-color
'@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@18.3.1))':
dependencies:
react-hook-form: 7.77.0(react@18.3.1)
@@ -23106,6 +23147,10 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
little-state-machine@4.8.1(react@19.2.6):
dependencies:
react: 19.2.6
load-esm@1.0.3: {}
load-json-file@1.1.0:
@@ -25095,6 +25140,10 @@ snapshots:
'@types/prop-types': 15.7.15
'@types/react': 18.3.31
react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)):
dependencies:
react-dom: 19.2.6(react@19.2.6)
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
fast-equals: 5.4.0