mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #205 from Tria-plc/freight_feature/priority
Freight feature/priority
This commit is contained in:
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
|
||||
# Portal pages the payment provider redirects the browser to after payment.
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -69,7 +68,12 @@ export class BookingTransitionService {
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
||||
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
||||
// arrives. The returned status reflects that outcome.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -143,7 +147,10 @@ export class BookingTransitionService {
|
||||
},
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Same consolidation treatment as the direct submit path.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -188,18 +195,11 @@ export class BookingTransitionService {
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,9 +118,23 @@ export class BookingsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||
// force-scoped to their own company, regardless of any companyId they pass.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
if (!companyId) return { items: [], total: 0 };
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@@ -166,18 +181,60 @@ export class BookingsController {
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
|
||||
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||
// pairing never prematurely submits an unfinished/unpriced draft.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -52,6 +56,8 @@ export class BookingsService {
|
||||
private readonly minioService: MinioService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -146,13 +152,17 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||
*
|
||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||
* whole wagons (where consolidation is moot anyway).
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (explicit === false) return false;
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
@@ -193,10 +203,11 @@ export class BookingsService {
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
if (booking.status === 'DRAFT') {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
// No partner yet — park the booking so it waits. Applies both pre-submit
|
||||
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
|
||||
// reach this method.
|
||||
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
}
|
||||
|
||||
const pending = await this.findById(booking.id);
|
||||
@@ -205,45 +216,24 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
|
||||
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
|
||||
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
|
||||
* waits for a later complementary booking to complete the wagon.
|
||||
*
|
||||
* Returns the re-fetched booking, so callers can reflect the resulting status
|
||||
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
// Already paired (e.g. a partner submitted first) — nothing to do.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
return booking;
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
return result.booking;
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
@@ -575,6 +565,7 @@ export class BookingsService {
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -587,7 +578,9 @@ export class BookingsService {
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
// A forced company scope (portal/customer) overrides any caller-provided
|
||||
// companyId so a customer can only ever see their own company's bookings.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
@@ -631,6 +624,109 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company a customer user belongs to, for scoping their own
|
||||
* bookings. Returns null when no profile/company is linked yet.
|
||||
*/
|
||||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
* to the company the authenticated user is linked to — otherwise it is hidden
|
||||
* behind a NotFound so booking IDs can't be probed.
|
||||
*/
|
||||
async assertCustomerCanAccessBooking(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||
if (!companyId || booking.companyId !== companyId) {
|
||||
// Don't reveal that the booking exists for another company.
|
||||
throw new NotFoundException(`Booking ${booking.id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
* responsible for authorizing access to the booking first.
|
||||
*
|
||||
* When the booking has not been assigned to a train yet, returns a valid
|
||||
* "no schedule" payload so the UI can show a pre-dispatch state.
|
||||
*/
|
||||
async getBookingTracking(
|
||||
bookingId: string,
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
const empty: Freight.IBookingTracking = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: false,
|
||||
scheduleId: null,
|
||||
trainNumber: null,
|
||||
scheduleStatus: null,
|
||||
direction: null,
|
||||
origin: null,
|
||||
destination: null,
|
||||
stations: [],
|
||||
checkpoints: [],
|
||||
currentSequenceNo: -1,
|
||||
actualDepartureAt: null,
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Pull the live corridor + checkpoints for the assigned schedule. If the
|
||||
// schedule was removed, fall back to the pre-dispatch state rather than 500.
|
||||
let track: Awaited<
|
||||
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
|
||||
>;
|
||||
try {
|
||||
track = await this.trainSchedulingService.getScheduleCheckpoints(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: true,
|
||||
scheduleId: track.scheduleId,
|
||||
trainNumber: track.trainNumber,
|
||||
scheduleStatus: track.status as Freight.TrainScheduleStatus,
|
||||
direction: track.direction,
|
||||
origin: track.origin,
|
||||
destination: track.destination,
|
||||
stations: track.stations,
|
||||
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
|
||||
currentSequenceNo: track.currentSequenceNo,
|
||||
actualDepartureAt: track.actualDepartureAt,
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
|
||||
@@ -145,9 +145,7 @@ export class PaymentService {
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -159,8 +157,8 @@ export class PaymentService {
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
||||
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||
}
|
||||
|
||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
||||
}
|
||||
}
|
||||
|
||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
});
|
||||
})
|
||||
: {
|
||||
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||
scheduleId,
|
||||
)),
|
||||
warnings: [] as string[],
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
|
||||
@@ -721,27 +721,37 @@ export class TrainSchedulingService {
|
||||
: null;
|
||||
|
||||
if (route) {
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
// `route.milestones` is the complete ordered corridor and already includes
|
||||
// the origin (first) and destination (last) yards — `route.originYardId`
|
||||
// and `route.destinationYardId` are derived from them. Use the milestones
|
||||
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||||
const milestones = [...(route.milestones ?? [])].sort(
|
||||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
|
||||
if (milestones.length > 0) {
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
return stations;
|
||||
}
|
||||
|
||||
// Route with no milestones recorded — fall back to its origin/destination.
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
stations.push({
|
||||
sequenceNo: 0,
|
||||
yardId: route.originYardId,
|
||||
label: origin?.label ?? origin?.code ?? 'Origin',
|
||||
code: origin?.code ?? '',
|
||||
});
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i + 1,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
stations.push({
|
||||
sequenceNo: milestones.length + 1,
|
||||
sequenceNo: 1,
|
||||
yardId: route.destinationYardId,
|
||||
label: destination?.label ?? destination?.code ?? 'Destination',
|
||||
code: destination?.code ?? '',
|
||||
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
|
||||
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
|
||||
// Resolve each checkpoint's position by its yard against the canonical
|
||||
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||||
// under an older station numbering still line up with the current stations.
|
||||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||||
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||
|
||||
const currentSequenceNo = events.length
|
||||
? Math.max(...events.map((e) => e.sequenceNo))
|
||||
? Math.max(...events.map(resolvedSeq))
|
||||
: -1;
|
||||
|
||||
return {
|
||||
@@ -790,13 +808,19 @@ export class TrainSchedulingService {
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? schedule.actualArrivalAt.toISOString()
|
||||
: null,
|
||||
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||||
? schedule.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||||
? schedule.scheduledArrivalDate.toISOString()
|
||||
: null,
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
sequenceNo: e.sequenceNo,
|
||||
sequenceNo: resolvedSeq(e),
|
||||
yardId: e.yardId,
|
||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||
kind: e.kind,
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Badge as MantineBadge } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import "@/components/overview/overview.css";
|
||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||
import type {
|
||||
PaymentMethod,
|
||||
@@ -39,11 +42,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||
{ key: "success", label: "Success", statuses: "success" },
|
||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||
{
|
||||
key: "processing",
|
||||
label: "Processing",
|
||||
statuses: "processing,action-required",
|
||||
icon: Loader2,
|
||||
},
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||
] as const;
|
||||
|
||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
||||
|
||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||
|
||||
const tabCounts: Record<StatusTabKey, number | undefined> = {
|
||||
all:
|
||||
summary === undefined
|
||||
? undefined
|
||||
: (summary.success ?? 0) +
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
success: summary?.success,
|
||||
processing: summary?.processing,
|
||||
failed: summary?.failed,
|
||||
refunded: summary?.refunded,
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PaymentRow>[] = [
|
||||
{
|
||||
id: "order",
|
||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
||||
setStatusTab((value as StatusTabKey) ?? "all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.key} value={t.key}>
|
||||
{STATUS_TABS.map((t) => {
|
||||
const isActive = statusTab === t.key;
|
||||
const count = tabCounts[t.key];
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={t.key}
|
||||
value={t.key}
|
||||
leftSection={<Icon size={17} strokeWidth={1.85} />}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<MantineBadge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</MantineBadge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -158,6 +160,9 @@ const App = () => {
|
||||
path="/booking/check-status/:orderId"
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
|
||||
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
<svg
|
||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="auth-grid" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
|
||||
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
||||
<LeftPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
Support
|
||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
{taglineBody ??
|
||||
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<span>Eng</span>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const FormFooter = () => (
|
||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||
Help & Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||
<RightPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
|
||||
interface BookingRowProps {
|
||||
booking: any;
|
||||
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
|
||||
const Icon = cfg.icon;
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||
// instead of navigating to the detail page.
|
||||
const canPay =
|
||||
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||
@@ -78,6 +83,9 @@ export const BookingRow = memo(function BookingRow({
|
||||
{cfg.badgeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
{canPay ? (
|
||||
<PayNowButton booking={booking} size="sm" />
|
||||
) : (
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
@@ -97,6 +105,7 @@ export const BookingRow = memo(function BookingRow({
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
|
||||
Outstanding balance
|
||||
</Text>
|
||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||
{formatCurrency(totalOutstanding || 377500, "ETB")}
|
||||
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
|
||||
@@ -1,39 +1,51 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
type LoginMethod = "email" | "phone";
|
||||
|
||||
const loginMethods: Array<{
|
||||
value: LoginMethod;
|
||||
label: string;
|
||||
icon: typeof Mail;
|
||||
placeholder: string;
|
||||
}> = [
|
||||
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
||||
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const [method, setMethod] = useState<LoginMethod>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("+251");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [countryCode] = useState("+251");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const currentMethod = loginMethods.find((item) => item.value === method)!;
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const loginId =
|
||||
method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
||||
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||
const result = await login({ email: loginId, password });
|
||||
if (result.success) {
|
||||
const from = (location.state as { from?: { pathname: string } } | null)
|
||||
?.from?.pathname;
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||
?.pathname;
|
||||
navigate(from ?? "/portal", { replace: true });
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
@@ -46,154 +58,105 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Welcome Back",
|
||||
title: "Sign in to your freight operations account",
|
||||
description:
|
||||
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box
|
||||
w={48}
|
||||
h={48}
|
||||
bg="edr-soft"
|
||||
className="flex items-center justify-center rounded-2xl"
|
||||
>
|
||||
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
<AuthShell>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Welcome back
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Enter your credentials to access your portal
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your credentials to access your freight portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v as LoginMethod)}
|
||||
fullWidth
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Mail size={15} />
|
||||
<Text size="sm">Email</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "email",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Phone size={15} />
|
||||
<Text size="sm">Phone</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "phone",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||
disabled={loading}
|
||||
className={`${fieldClass} appearance-none pr-10`}
|
||||
>
|
||||
{loginMethods.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{method === "email" ? (
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="name@company.com"
|
||||
type="email"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
{currentMethod.label} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={currentMethod.placeholder}
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
/>
|
||||
) : (
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{
|
||||
value: countryCode,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCountryCode(e.target.value),
|
||||
}}
|
||||
phone={{
|
||||
value: phoneNumber,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPhoneNumber(e.target.value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Password
|
||||
</Text>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="xs"
|
||||
c="edr-green.6"
|
||||
p={0}
|
||||
h="auto"
|
||||
fz={12}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||
Forgot password?
|
||||
</Button>
|
||||
</Group>
|
||||
<PasswordInput
|
||||
placeholder="••••••••"
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
@@ -9,8 +8,9 @@ import { z } from "zod";
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
@@ -20,11 +20,22 @@ const passwordRequirements = [
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] as const;
|
||||
|
||||
const ETHIOPIA_COUNTRY_CODE = "+251";
|
||||
|
||||
const isValidEthiopianMobile = (value: string) => {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||
return /^9\d{8}$/.test(normalized);
|
||||
};
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
countryCode: z.string().min(1, "Country code is required"),
|
||||
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
|
||||
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
@@ -44,11 +55,16 @@ const userSchema = z
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
const errorText = (msg?: string) =>
|
||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -59,7 +75,7 @@ export default function SignupPage() {
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
countryCode: "+251",
|
||||
countryCode: ETHIOPIA_COUNTRY_CODE,
|
||||
phone: "",
|
||||
userType: userType.individual,
|
||||
firstName: { en: "", am: "" },
|
||||
@@ -73,7 +89,8 @@ export default function SignupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
|
||||
const digits = data.phone.replace(/\D/g, "");
|
||||
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
@@ -102,145 +119,194 @@ export default function SignupPage() {
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Smart Freight Operations",
|
||||
title: "Create your freight operations account",
|
||||
description:
|
||||
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Create Account
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="First Name"
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
error={errors.firstName?.en?.message}
|
||||
className={fieldClass}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
{errorText(errors.firstName?.en?.message)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
error={errors.lastName?.en?.message}
|
||||
className={fieldClass}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{errorText(errors.lastName?.en?.message)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
disabled={loading}
|
||||
error={errors.email?.message}
|
||||
className={fieldClass}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errorText(errors.email?.message)}
|
||||
</div>
|
||||
|
||||
<PhoneInput
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="hidden" {...register("countryCode")} />
|
||||
<div
|
||||
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
|
||||
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
|
||||
{ETHIOPIA_COUNTRY_CODE}
|
||||
</span>
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel-national"
|
||||
placeholder="0912345678"
|
||||
maxLength={10}
|
||||
disabled={loading}
|
||||
countryCode={{ ...register("countryCode") }}
|
||||
phone={{ ...register("phone") }}
|
||||
countryCodeError={errors.countryCode}
|
||||
phoneError={errors.phone}
|
||||
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
|
||||
{...register("phone", {
|
||||
onChange: (event) => {
|
||||
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errorText(errors.phone?.message)}
|
||||
</div>
|
||||
|
||||
<Box>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password?.message}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 && (
|
||||
<Stack gap={4} mt={8}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.password?.message)}
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={16}
|
||||
radius="xl"
|
||||
variant={met ? "filled" : "light"}
|
||||
color={met ? "edr-green" : "gray"}
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check size={10} /> : <X size={10} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword?.message}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.confirmPassword?.message)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
}}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
|
||||
@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<SectionCard
|
||||
p={22}
|
||||
style={
|
||||
remaining.expired
|
||||
? undefined
|
||||
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment deadline</CardTitle>
|
||||
<Group
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Smartphone, type LucideIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
@@ -8,7 +8,10 @@ interface ProviderOption {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
logo: string;
|
||||
/** Currencies this provider settles in. */
|
||||
currencies: string[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
label: "telebirr",
|
||||
description: "Ethiopian mobile money",
|
||||
icon: Smartphone,
|
||||
description: "Ethiopian mobile money · ETB",
|
||||
logo: "/assets/telebirr.jpeg",
|
||||
currencies: ["ETB"],
|
||||
accent: "#0A6F4D",
|
||||
},
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "Waafi",
|
||||
description: "Djibouti mobile money",
|
||||
icon: Smartphone,
|
||||
description: "Djibouti mobile money · USD",
|
||||
logo: "/assets/waafi.jpeg",
|
||||
currencies: ["USD"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||
*/
|
||||
function providersForCurrency(currency?: string | null): ProviderOption[] {
|
||||
const cur = currency?.trim().toUpperCase();
|
||||
if (!cur) return PROVIDERS;
|
||||
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
return matched.length > 0 ? matched : PROVIDERS;
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
option,
|
||||
selected,
|
||||
@@ -36,55 +54,75 @@ function ProviderRow({
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Group
|
||||
onClick={onSelect}
|
||||
gap={12}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
gap={14}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
padding: "13px 14px",
|
||||
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
||||
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
||||
transition: "border-color .12s, background-color .12s",
|
||||
borderRadius: 14,
|
||||
padding: "14px 16px",
|
||||
border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
|
||||
backgroundColor: selected ? "#F6FBF8" : "#fff",
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
|
||||
: "none",
|
||||
transition: "border-color .14s, box-shadow .14s, background-color .14s",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
||||
color: selected ? "#fff" : "#475569",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
border: "1px solid #EEF2F6",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fz="14px" fw={700} c="#10202F">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
<Text fz="12.5px" c="#7A8794" truncate>
|
||||
{option.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
width: 22,
|
||||
height: 22,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
||||
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
||||
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
||||
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
|
||||
backgroundColor: selected ? option.accent : "transparent",
|
||||
transition: "all .14s",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
|
||||
opened,
|
||||
onClose,
|
||||
amountLabel,
|
||||
currency,
|
||||
onConfirm,
|
||||
processing,
|
||||
error,
|
||||
@@ -101,34 +140,73 @@ export function PaymentMethodModal({
|
||||
onClose: () => void;
|
||||
/** Human-readable total, e.g. "ETB 12,500". */
|
||||
amountLabel?: string;
|
||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||
currency?: string | null;
|
||||
onConfirm: (method: PaymentMethod) => void;
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
|
||||
const providers = useMemo(() => providersForCurrency(currency), [currency]);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
if (!providers.some((p) => p.method === method)) {
|
||||
setMethod(providers[0].method);
|
||||
}
|
||||
}, [providers, method]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size={460}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fw={800} fz="17px" c="#10202F">
|
||||
Choose a payment method
|
||||
</Text>
|
||||
{amountLabel && (
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
Amount due: {amountLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
radius={18}
|
||||
size={480}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box px={24} pt={24} pb={18}>
|
||||
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
|
||||
Complete your payment
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
Choose how you'd like to pay for this booking.
|
||||
</Text>
|
||||
|
||||
{amountLabel && (
|
||||
<Group
|
||||
mt={16}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background:
|
||||
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
|
||||
border: "1px solid #F2E4C4",
|
||||
}}
|
||||
>
|
||||
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Amount due
|
||||
</Text>
|
||||
<Text fz="20px" fw={800} c="#10202F">
|
||||
{amountLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Provider options */}
|
||||
<Box px={24} pb={4}>
|
||||
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
|
||||
Payment method
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{PROVIDERS.map((option) => (
|
||||
{providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
@@ -136,32 +214,52 @@ export function PaymentMethodModal({
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box px={24} pt={16} pb={22}>
|
||||
{error && (
|
||||
<Text fz="12.5px" c="#C0392B" fw={600}>
|
||||
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap={6} align="center" justify="center" mb={12}>
|
||||
<ShieldCheck size={14} color="#0A8A5F" />
|
||||
<Text fz="11.5px" c="#7A8794">
|
||||
Secured · you'll be redirected to your provider to pay
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Button
|
||||
fullWidth
|
||||
mt={6}
|
||||
radius={10}
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
styles={{
|
||||
root: { height: 48, flex: "0 0 38%" },
|
||||
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
disabled={processing}
|
||||
loading={processing}
|
||||
onClick={() => onConfirm(method)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
root: { height: 48, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
</Button>
|
||||
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
||||
You'll be redirected to your provider to complete payment securely.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,92 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { AlertTriangle, Check, FileText, History } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FileText,
|
||||
History,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
||||
import { fmtDate, isDraftLike, isNegative } from "../utils";
|
||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||
import { SectionCard } from "./layout";
|
||||
|
||||
const ACCENT = "#F2A516";
|
||||
|
||||
/** Origin → destination strip rendered above the progress tracker. */
|
||||
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
|
||||
const origin = yardLabel(booking.originYard);
|
||||
const destination = yardLabel(booking.destinationYard);
|
||||
return (
|
||||
<Box
|
||||
mb={22}
|
||||
px={18}
|
||||
py={14}
|
||||
className="rounded-2xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
|
||||
border: "1px solid #F2E4C4",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<RouteEndpoint label="Origin" value={origin} />
|
||||
<Box
|
||||
className="flex items-center justify-center rounded-full shrink-0"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: "#fff",
|
||||
border: `1px solid ${ACCENT}33`,
|
||||
color: ACCENT,
|
||||
}}
|
||||
>
|
||||
<MoveRight size={18} />
|
||||
</Box>
|
||||
<RouteEndpoint label="Destination" value={destination} alignRight />
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteEndpoint({
|
||||
label,
|
||||
value,
|
||||
alignRight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
alignRight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify={alignRight ? "flex-end" : "flex-start"}
|
||||
>
|
||||
<MapPin size={12} color={ACCENT} />
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
c="#B07D14"
|
||||
tt="uppercase"
|
||||
className="tracking-[0.6px]"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusHero({
|
||||
booking,
|
||||
children,
|
||||
@@ -91,6 +171,8 @@ export function StatusHero({
|
||||
|
||||
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
|
||||
|
||||
{!negative && <RouteStrip booking={booking} />}
|
||||
|
||||
{children ?? (
|
||||
<ProgressTracker
|
||||
current={cfg.stage}
|
||||
|
||||
@@ -70,11 +70,11 @@ export function EstimateCard({
|
||||
mb={4}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#F1F4F7",
|
||||
backgroundColor: "#FEF6E6",
|
||||
padding: "3px 7px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#6B7C8E",
|
||||
color: "#B07D14",
|
||||
}}
|
||||
>
|
||||
est.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -8,14 +8,44 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileEdit,
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
|
||||
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"COMPLETED",
|
||||
"DELIVERED",
|
||||
]);
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -24,25 +54,86 @@ import {
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
|
||||
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
|
||||
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
|
||||
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
|
||||
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
|
||||
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
|
||||
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
|
||||
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
|
||||
};
|
||||
const STATUS_FILTERS = [
|
||||
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
statuses:
|
||||
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
|
||||
},
|
||||
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
const STAT_CARDS: Array<{
|
||||
key: StatusFilterKey;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status] ?? {
|
||||
bg: "#F1F4F7",
|
||||
dot: "#94A3B8",
|
||||
color: "#475569",
|
||||
label: status.replace(/_/g, " "),
|
||||
};
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
|
||||
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: cfg.bg,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: cfg.dot,
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
|
||||
{cfg.label}
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
|
||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||
|
||||
function PrimaryAction({
|
||||
status,
|
||||
id,
|
||||
booking,
|
||||
onNavigate,
|
||||
}: {
|
||||
status: string;
|
||||
id: string;
|
||||
booking: Freight.IBooking;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
@@ -89,57 +180,24 @@ function PrimaryAction({
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (status === "AWAITING_PAYMENT") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (status === "IN_TRANSIT") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
);
|
||||
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
||||
>
|
||||
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column header label ───────────────────────────────────────────────────────
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Text
|
||||
@@ -155,20 +213,164 @@ function ColHeader({ label }: { label: string }) {
|
||||
|
||||
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
return data?.total;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
card,
|
||||
active,
|
||||
count,
|
||||
onSelect,
|
||||
}: {
|
||||
card: (typeof STAT_CARDS)[number];
|
||||
active: boolean;
|
||||
count: number | undefined;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Paper
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "box-shadow 140ms ease, border-color 140ms ease",
|
||||
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
|
||||
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: card.iconBg,
|
||||
color: card.iconColor,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={24} fw={800} lh={1.05} c="edr-text">
|
||||
{count ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{card.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyBookings() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
|
||||
const bookings = data?.items ?? [];
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
const total = bookings.length;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
|
||||
const selectFilter = (key: StatusFilterKey) => {
|
||||
setStatusFilter(key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
[statuses, pagination.pageIndex, pagination.pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
active: activeCount,
|
||||
payment: paymentCount,
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
closed: undefined,
|
||||
};
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
const total = data?.total ?? allItems.length;
|
||||
|
||||
// Server handles status + pagination; reference search is applied on the page.
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allItems;
|
||||
return allItems.filter((b) =>
|
||||
[b.reference, b.originYard?.label, b.destinationYard?.label]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allItems, query]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty =
|
||||
!isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
@@ -178,8 +380,7 @@ export default function MyBookings() {
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
|
||||
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -217,7 +418,7 @@ export default function MyBookings() {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
||||
const sub = b.scheduledDate ?? b.createdAt ?? "";
|
||||
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
@@ -245,7 +446,10 @@ export default function MyBookings() {
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
|
||||
const b = row.original as Freight.IBooking & {
|
||||
totalAmount?: number;
|
||||
amount?: number;
|
||||
};
|
||||
const amount = b.totalAmount ?? b.amount ?? null;
|
||||
if (!amount) {
|
||||
return (
|
||||
@@ -267,24 +471,42 @@ export default function MyBookings() {
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<Train size={14} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
||||
View Details
|
||||
View details
|
||||
</Menu.Item>
|
||||
{trackable && (
|
||||
<Menu.Item
|
||||
leftSection={<Train size={15} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track shipment
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -293,8 +515,6 @@ export default function MyBookings() {
|
||||
},
|
||||
];
|
||||
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
@@ -305,13 +525,9 @@ export default function MyBookings() {
|
||||
Bookings
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Manage every cargo booking — from draft to delivery.
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={12}>
|
||||
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
@@ -319,51 +535,85 @@ export default function MyBookings() {
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New Booking
|
||||
New booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
|
||||
{STAT_CARDS.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
card={card}
|
||||
active={statusFilter === card.key}
|
||||
count={cardCounts[card.key]}
|
||||
onSelect={() => selectFilter(card.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Bookings table card ──────────────────────────────────────── */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Toolbar */}
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
justify="space-between"
|
||||
gap={12}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
placeholder="Search reference or route…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
radius="md"
|
||||
leftSection={<ArrowUpDown size={14} />}
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
Sort
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
leftSection={<Filter size={14} />}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
|
||||
/>
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 200 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{total} booking{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Empty state */}
|
||||
{total === 0 && dataTableStatus === "success" ? (
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
No bookings yet
|
||||
{query ? "No bookings match your search" : "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
You haven't made any booking requests yet. Create your first one to get started.
|
||||
{query
|
||||
? "Try a different reference or clear the search."
|
||||
: "Create your first booking to get started."}
|
||||
</Text>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
@@ -375,11 +625,12 @@ export default function MyBookings() {
|
||||
>
|
||||
Create first booking
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||
pagination={{
|
||||
@@ -391,6 +642,8 @@ export default function MyBookings() {
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
@@ -398,6 +651,20 @@ export default function MyBookings() {
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingBooking !== null}
|
||||
onClose={() => setTrackingBooking(null)}
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
trackingBooking?.destinationYard?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,62 +2,67 @@ import { Check } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { STEPS } from "./schema";
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-5)";
|
||||
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
|
||||
const BORDER = "var(--mantine-color-edr-border-0)";
|
||||
const MUTED = "var(--mantine-color-edr-muted-0)";
|
||||
const INK = "var(--mantine-color-edr-text-0)";
|
||||
|
||||
export function StepIndicator({ step }: { step: number }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((item, index) => (
|
||||
<div className="flex items-start">
|
||||
{STEPS.map((item, index) => {
|
||||
const done = step > item.id;
|
||||
const active = step === item.id;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-1">
|
||||
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
transition: "all 0.2s",
|
||||
...(step > item.id
|
||||
...(done
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-edr-green-5)",
|
||||
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
|
||||
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
|
||||
}
|
||||
: step === item.id
|
||||
: active
|
||||
? {
|
||||
border: "2.5px solid var(--mantine-color-edr-green-5)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
border: `2.5px solid ${GREEN}`,
|
||||
color: GREEN_DEEP,
|
||||
backgroundColor: "#fff",
|
||||
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
|
||||
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "#fff",
|
||||
color: "var(--mantine-color-edr-muted-0)",
|
||||
border: "2px solid var(--mantine-color-edr-border-0)",
|
||||
color: MUTED,
|
||||
border: `2px solid ${BORDER}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{step > item.id ? (
|
||||
<Check style={{ width: 13, height: 13 }} />
|
||||
) : (
|
||||
item.id
|
||||
)}
|
||||
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
fontSize: 11,
|
||||
fontWeight: active ? 700 : 500,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: 72,
|
||||
display: "none",
|
||||
transition: "color 0.2s",
|
||||
color:
|
||||
step >= item.id
|
||||
? "var(--mantine-color-edr-text-0)"
|
||||
: "var(--mantine-color-edr-muted-0)",
|
||||
color: step >= item.id ? INK : MUTED,
|
||||
}}
|
||||
className="lg:!block"
|
||||
className="md:!block"
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
@@ -66,20 +71,19 @@ export function StepIndicator({ step }: { step: number }) {
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
margin: "0 6px",
|
||||
marginBottom: 14,
|
||||
transition: "background-color 0.3s",
|
||||
backgroundColor:
|
||||
step > item.id
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-edr-border-0)",
|
||||
margin: "16px 8px 0",
|
||||
transition: "background 0.3s",
|
||||
background: done
|
||||
? "linear-gradient(90deg, #0A8A5F, #12B981)"
|
||||
: BORDER,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,167 @@
|
||||
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
|
||||
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Paper,
|
||||
Select,
|
||||
Text,
|
||||
Title,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import type { BookingFormInputValues } from "./schema";
|
||||
|
||||
// Brand tokens (kept local so the form reads consistently with the booking
|
||||
// detail page and the scheduling step).
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const GREEN = "#0EA371";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||
if (!error?.message) return null;
|
||||
return (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{error.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium selectable option card with an icon tile, title, and description.
|
||||
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
|
||||
* `children` (legacy callers still work).
|
||||
*/
|
||||
export function OptionCard({
|
||||
selected,
|
||||
onClick,
|
||||
disabled,
|
||||
icon,
|
||||
iconBg = "#ECF6F1",
|
||||
iconColor = GREEN_DARK,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconBg?: string;
|
||||
iconColor?: string;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 150ms ease",
|
||||
border: `1.5px solid ${
|
||||
disabled ? BORDER : selected ? GREEN : BORDER
|
||||
}`,
|
||||
background: disabled
|
||||
? "#F6F8FA"
|
||||
: selected
|
||||
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
|
||||
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
|
||||
}`}
|
||||
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
|
||||
: "#FFFFFF",
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
opacity: disabled ? 0.65 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = "#BFE3D2";
|
||||
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled && !selected) {
|
||||
e.currentTarget.style.borderColor = BORDER;
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected && !disabled && (
|
||||
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
|
||||
<Check className="h-3 w-3 text-white" />
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
height: 22,
|
||||
width: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
background: GREEN,
|
||||
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
|
||||
}}
|
||||
>
|
||||
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Structured form (icon + title + description) */}
|
||||
{(icon || title || description) && (
|
||||
<Box>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: "flex",
|
||||
height: 42,
|
||||
width: 42,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
{title && (
|
||||
<Text fz={15} fw={800} c={INK}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -63,7 +182,7 @@ export function AlertBox({
|
||||
};
|
||||
const { color, icon } = map[tone];
|
||||
return (
|
||||
<Alert color={color} icon={icon} radius="md" fz="sm">
|
||||
<Alert color={color} icon={icon} radius="lg" fz="sm">
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
@@ -71,31 +190,89 @@ export function AlertBox({
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={MUTED}
|
||||
style={{ letterSpacing: "0.07em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card shell that wraps a step's body. Gives every step the same premium
|
||||
* surface, padding, and an optional eyebrow.
|
||||
*/
|
||||
export function StepCard({
|
||||
children,
|
||||
eyebrow,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p={{ base: "lg", sm: 28 }}
|
||||
withBorder
|
||||
bg="white"
|
||||
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||
>
|
||||
{eyebrow}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Title order={3} className="tracking-tight">
|
||||
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||
{icon && (
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||
color: GREEN_DARK,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared Mantine input styling so every field in the form matches. */
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
|
||||
} as const;
|
||||
|
||||
export function SelectField({
|
||||
field,
|
||||
error,
|
||||
@@ -103,6 +280,7 @@ export function SelectField({
|
||||
placeholder,
|
||||
disabled,
|
||||
data,
|
||||
leftSection,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormInputValues>;
|
||||
error?: RhfFieldError;
|
||||
@@ -110,6 +288,7 @@ export function SelectField({
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
data: string[] | { value: string; label: string }[];
|
||||
leftSection?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
@@ -122,6 +301,11 @@ export function SelectField({
|
||||
onBlur={field.onBlur}
|
||||
error={error?.message}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
|
||||
};
|
||||
|
||||
return (
|
||||
<Input.Wrapper label={label} error={error?.message}>
|
||||
<Combobox store={combobox} disabled={disabled}>
|
||||
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
|
||||
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircle2, FileUp } from "lucide-react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BookingDocuments,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
const total = BOOKING_DOCS_SETTING.fields.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileUp size={22} />}
|
||||
title="Shipment Documents"
|
||||
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
|
||||
description="Attach your shipment documents now, or skip and upload them later from the booking page."
|
||||
/>
|
||||
|
||||
<Group
|
||||
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
|
||||
centered
|
||||
size="sm"
|
||||
styles={{
|
||||
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
|
||||
body: { padding: 24 },
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
<Text fz={13} c="edr-muted" fw={500}>
|
||||
Choose a departure time
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
{selectedDayForModal?.schedules.map((schedule) => (
|
||||
<Button
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
variant="outline"
|
||||
fullWidth
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
style={{ height: 64, justifyContent: "flex-start" }}
|
||||
styles={{
|
||||
inner: { justifyContent: "flex-start" },
|
||||
root: {
|
||||
borderColor: theme.colors["edr-border"][0],
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition: "all 150ms ease",
|
||||
"&:hover": {
|
||||
borderColor: theme.colors["edr-green"][5],
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group gap={16} w="100%">
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][5]} />
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
|
||||
<Text fw={700} fz={18} c="edr-text.0">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
{schedule.trainNumber && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
Train {schedule.trainNumber}
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Button>
|
||||
))}
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Schedule times */}
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{d.hasSchedule && (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
|
||||
{d.schedules.slice(0, 2).map((s) => (
|
||||
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={12}
|
||||
fw={700}
|
||||
c="edr-text.0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{format(new Date(s.scheduleDate), "HH:mm")}
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{d.schedules.length > 2 && (
|
||||
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
|
||||
+{d.schedules.length - 2} more
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
AsyncComboboxField,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
import { FileSignature } from "lucide-react";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -52,7 +55,6 @@ export function Step1ContractType({
|
||||
);
|
||||
|
||||
const contractOptions = useMemo<PreviousContractOption[]>(() => {
|
||||
console.log("Bookings data:", bookings);
|
||||
if (!bookings) return [];
|
||||
|
||||
return bookings?.items
|
||||
@@ -182,10 +184,11 @@ export function Step1ContractType({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileSignature size={22} />}
|
||||
title="Contract Type"
|
||||
description="New contract or renewal of an existing one."
|
||||
description="Start a new contract or renew an existing one to reuse its details."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -193,40 +196,33 @@ export function Step1ContractType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value === "new"}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="New Contract"
|
||||
description="Create a fresh freight contract from scratch."
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
form.setValue("previousContractRef", "");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<FileText className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">New Contract</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Create a new contract.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
|
||||
<OptionCard
|
||||
selected={field.value === "renewal"}
|
||||
icon={<RefreshCw className="h-5 w-5" />}
|
||||
iconBg="#EAF1FB"
|
||||
iconColor="#2E5B96"
|
||||
title="Contract Renewal"
|
||||
description="Pick a previous reference to auto-fill historical parameters."
|
||||
onClick={() => {
|
||||
field.onChange("renewal");
|
||||
form.clearErrors("contractType");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
|
||||
<RefreshCw className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Contract Renewal</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Select a previous reference to auto-populate historical
|
||||
parameters.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -234,7 +230,7 @@ export function Step1ContractType({
|
||||
/>
|
||||
|
||||
{contractType === "renewal" && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<Stack gap={12} mt={22}>
|
||||
{error && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load previous contracts. Please try again later.
|
||||
@@ -263,8 +259,8 @@ export function Step1ContractType({
|
||||
details will be pre-filled.
|
||||
</AlertBox>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
import { FileText, Train, Truck } from "lucide-react";
|
||||
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileText, Layers, Train, Truck } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
import {
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -61,10 +69,11 @@ export function Step2ServiceType({
|
||||
const showServiceSections =
|
||||
includesCustoms || includesFirstMile || includesLastMile;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Layers size={22} />}
|
||||
title="Service Type"
|
||||
description="Select the service combination and configure trucking options."
|
||||
description="Choose the service combination, then configure your trucking options."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -72,25 +81,21 @@ export function Step2ServiceType({
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{referenceData?.service
|
||||
.filter((s) => s.canBeBookedAlone)
|
||||
.map((s) => {
|
||||
return (
|
||||
.map((s) => (
|
||||
<OptionCard
|
||||
key={s.id}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
icon={<Train className="h-5 w-5" />}
|
||||
iconBg="#EEF0FB"
|
||||
iconColor="#4F46E5"
|
||||
title={s.serviceName}
|
||||
description={s.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -98,31 +103,20 @@ export function Step2ServiceType({
|
||||
/>
|
||||
|
||||
{showServiceSections && (
|
||||
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
|
||||
<Stack gap={12} mt={24}>
|
||||
<StepLabel>Trucking & customs options</StepLabel>
|
||||
{/* First Mile */}
|
||||
{includesFirstMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="firstMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
First Mile — Pick-up
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck pick-up from your premises (Door to Port) to the
|
||||
origin rail yard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="First Mile — Pick-up"
|
||||
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("firstMile.pickUpAddress", "", {
|
||||
@@ -131,53 +125,40 @@ export function Step2ServiceType({
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="firstMile.pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Pick-up address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last Mile */}
|
||||
{includesLastMile && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="lastMile.enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Last Mile — Delivery
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Truck delivery from the destination rail yard to the
|
||||
final address (Port to Door).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Last Mile — Delivery"
|
||||
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
|
||||
checked={field.value}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.checked;
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("lastMile.deliveryAddress", "", {
|
||||
@@ -189,94 +170,134 @@ export function Step2ServiceType({
|
||||
});
|
||||
}
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="lastMile.deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
render={({ field: af, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
{...af}
|
||||
mt="sm"
|
||||
placeholder="Delivery address *"
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ServiceToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Equipment Return */}
|
||||
{includesLastMile && lastMileEnabled && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Equipment Return</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{field.value === "with_return"
|
||||
<ServiceToggle
|
||||
icon={<Truck size={18} />}
|
||||
title="Equipment Return"
|
||||
description={
|
||||
field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
: "Container retained by the customer after delivery."
|
||||
}
|
||||
checked={field.value === "with_return"}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.currentTarget.checked
|
||||
? "with_return"
|
||||
: "without_return",
|
||||
);
|
||||
}}
|
||||
color="edr-green"
|
||||
onChange={(v) =>
|
||||
field.onChange(v ? "with_return" : "without_return")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customs Clearing */}
|
||||
{includesCustoms && (
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="customsClearingEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Customs Clearing Service
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
EDR handles customs documentation and clearance on
|
||||
your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ServiceToggle
|
||||
icon={<FileText size={18} />}
|
||||
title="Customs Clearing Service"
|
||||
description="EDR handles customs documentation and clearance on your behalf."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#ECF6F1" : "#F1F4F7",
|
||||
color: checked ? "#0A6F4D" : "#64748B",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
|
||||
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type BookingFormValues,
|
||||
getRouteDirection,
|
||||
} from "./schema";
|
||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -63,7 +63,6 @@ export function Step4Route({
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
@@ -85,10 +84,11 @@ export function Step4Route({
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<RouteIcon size={22} />}
|
||||
title="Route"
|
||||
description="Select the origin and destination yards."
|
||||
description="Choose the origin and destination yards for your shipment."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -96,7 +96,7 @@ export function Step4Route({
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
control={form.control}
|
||||
@@ -153,56 +153,108 @@ export function Step4Route({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Divider my={22} color="#EEF2F6" />
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hazardous Material</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies a Hazard Surcharge to the final bill.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge to the final bill."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Refrigerated Cargo</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Temperature-controlled transport applies a Refrigerator
|
||||
Surcharge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a refrigeration surcharge."
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: iconBg,
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Skeleton,
|
||||
InputLabel,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
@@ -17,9 +16,11 @@ import {
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
fieldStyles,
|
||||
OptionCard,
|
||||
OptionFieldError,
|
||||
SelectField,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
|
||||
<div className="space-y-4">
|
||||
<Skeleton height={14} w={96} radius="sm" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="xl" />
|
||||
<Skeleton height={96} radius="xl" />
|
||||
</div>
|
||||
<Skeleton height={40} radius="md" />
|
||||
<Skeleton height={40} w="33%" radius="md" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton height={96} radius="lg" />
|
||||
<Skeleton height={96} radius="lg" />
|
||||
</div>
|
||||
<Skeleton height={44} radius="md" />
|
||||
<Skeleton height={44} w="33%" radius="md" />
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
|
||||
{/* Cargo Type */}
|
||||
<div className="space-y-3">
|
||||
<InputLabel>Cargo Type *</InputLabel>
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={cargoType === "container"}
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="Containerized"
|
||||
description="Pre-packed containerized cargo (20ft / 40ft)."
|
||||
onClick={() => {
|
||||
field.onChange("container");
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<Package className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Containerized</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Pre-packed containerized cargo (20ft / 40ft).
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
<OptionCard
|
||||
selected={cargoType === "bulk"}
|
||||
icon={<Weight className="h-5 w-5" />}
|
||||
iconBg="#FDF3E0"
|
||||
iconColor="#C77F09"
|
||||
title="General Cargo"
|
||||
description="Bulk commodities or break-bulk cargo."
|
||||
onClick={() => {
|
||||
field.onChange("bulk");
|
||||
form.setValue("containers", [], { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Weight className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<p className="font-semibold">General Cargo</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
Bulk commodities or break-bulk cargo.
|
||||
</p>
|
||||
</OptionCard>
|
||||
/>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
|
||||
placeholder="0.00"
|
||||
leftSection={<Weight className="h-4 w-4" />}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
@@ -154,6 +155,7 @@ export function Step8Review({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<StepHeader
|
||||
icon={<ClipboardCheck size={22} />}
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
import { priceTotal } from "../BookingDetailPage/utils";
|
||||
import { useBookingPayment } from "./useBookingPayment";
|
||||
|
||||
interface PayNowButtonProps {
|
||||
booking: Freight.IBooking;
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained "Pay now" action: shows the payment-method modal in place
|
||||
* instead of navigating to the booking detail page. Drop it into list rows,
|
||||
* cards, or anywhere a payable booking surfaces.
|
||||
*/
|
||||
export function PayNowButton({
|
||||
booking,
|
||||
label = "Pay now",
|
||||
size = "xs",
|
||||
fullWidth,
|
||||
}: PayNowButtonProps) {
|
||||
const pay = useBookingPayment(booking.id);
|
||||
const pricing = booking.pricingBreakdown;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
// Don't let a surrounding row-click handler fire.
|
||||
e.stopPropagation();
|
||||
pay.open();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /payments/initiate, and redirects the browser to the provider (or the
|
||||
* fallback checkout page). Reused by the booking detail page, the booking list,
|
||||
* and the home page so "Pay now" behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.payments.initiate.call({ bookingId, method }),
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const open = () => setModalOpen(true);
|
||||
|
||||
const close = () => {
|
||||
if (!mutation.isPending) {
|
||||
setModalOpen(false);
|
||||
mutation.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const error = mutation.isError
|
||||
? mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
|
||||
return {
|
||||
modalOpen,
|
||||
open,
|
||||
close,
|
||||
processing: mutation.isPending,
|
||||
error,
|
||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageX,
|
||||
RefreshCw,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
checkpointKindLabel,
|
||||
corridorProgress,
|
||||
isArrived,
|
||||
isDispatched,
|
||||
shipmentStatusLabel,
|
||||
} from "./trackingStages";
|
||||
|
||||
const GREEN = "#0EA371";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
const ACCENT = "#F2A516";
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
|
||||
interface ShipmentTrackingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
originLabel?: string;
|
||||
destinationLabel?: string;
|
||||
}
|
||||
|
||||
export function ShipmentTrackingModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
bookingReference,
|
||||
originLabel,
|
||||
destinationLabel,
|
||||
}: ShipmentTrackingModalProps) {
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
refetchInterval: opened ? 30_000 : false,
|
||||
});
|
||||
|
||||
const hasSchedule = data?.hasSchedule ?? false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size={900}
|
||||
radius={20}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
|
||||
styles={{ content: { overflow: "hidden" } }}
|
||||
>
|
||||
<Header
|
||||
bookingReference={data?.bookingReference ?? bookingReference}
|
||||
trainNumber={data?.trainNumber ?? null}
|
||||
status={data?.scheduleStatus ?? null}
|
||||
currentSequenceNo={data?.currentSequenceNo ?? -1}
|
||||
onClose={onClose}
|
||||
onRefresh={() => refetch()}
|
||||
refreshing={isFetching}
|
||||
/>
|
||||
|
||||
<Box px={28} py={24}>
|
||||
{isLoading ? (
|
||||
<Center mih={280}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader color="edr-green" />
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Locating your train…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<ErrorState onRetry={() => refetch()} />
|
||||
) : !hasSchedule ? (
|
||||
<NotDispatchedState
|
||||
origin={originLabel ?? "Origin"}
|
||||
destination={destinationLabel ?? "Destination"}
|
||||
/>
|
||||
) : data ? (
|
||||
<Stack gap={26}>
|
||||
<SummaryBar data={data} />
|
||||
<Corridor data={data} />
|
||||
<CheckpointFeed data={data} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Header({
|
||||
bookingReference,
|
||||
trainNumber,
|
||||
status,
|
||||
currentSequenceNo,
|
||||
onClose,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
bookingReference: string;
|
||||
trainNumber: string | null;
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
px={28}
|
||||
py={22}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={14} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 13,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(255,255,255,0.12)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Train size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="#9FE9CC"
|
||||
style={{ letterSpacing: 0.7 }}
|
||||
>
|
||||
Live shipment tracking
|
||||
</Text>
|
||||
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
|
||||
{bookingReference}
|
||||
</Text>
|
||||
{trainNumber && (
|
||||
<Text fz="12px" c="#A9BBCB">
|
||||
Train {trainNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
|
||||
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
<IconButton title="Close" onClick={onClose}>
|
||||
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
|
||||
</IconButton>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
spinning,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 9,
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderStatusPill({
|
||||
status,
|
||||
currentSequenceNo,
|
||||
}: {
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
}) {
|
||||
const arrived = isArrived(status);
|
||||
const moving = isDispatched(status);
|
||||
const bg = arrived
|
||||
? "rgba(14,163,113,0.22)"
|
||||
: moving
|
||||
? "rgba(242,165,22,0.20)"
|
||||
: "rgba(255,255,255,0.12)";
|
||||
const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
|
||||
return (
|
||||
<Group
|
||||
gap={7}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={12}
|
||||
py={7}
|
||||
style={{ borderRadius: 999, backgroundColor: bg }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
|
||||
}}
|
||||
/>
|
||||
<Text fz="12px" fw={700} c="#fff">
|
||||
{shipmentStatusLabel(status, currentSequenceNo)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
|
||||
|
||||
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
||||
const arrived = isArrived(data.scheduleStatus);
|
||||
const items: Array<{ label: string; value: string; accent?: boolean }> = [
|
||||
{
|
||||
label: "Departed",
|
||||
value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
|
||||
},
|
||||
{
|
||||
label: arrived ? "Arrived" : "Est. arrival",
|
||||
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
|
||||
accent: !arrived,
|
||||
},
|
||||
{
|
||||
label: "Stations",
|
||||
value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<Box
|
||||
key={it.label}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "14px 16px",
|
||||
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
|
||||
background: it.accent ? "#FEFBF3" : "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={it.accent ? "#B07D14" : MUTED}
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{it.label}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c={INK} mt={2}>
|
||||
{it.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corridor: stations + train marker ──────────────────────────────────────────
|
||||
|
||||
function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||
const arrived = isArrived(data.scheduleStatus);
|
||||
const moving = isDispatched(data.scheduleStatus);
|
||||
const stations = data.stations;
|
||||
const current = data.currentSequenceNo;
|
||||
const progress = corridorProgress(stations.length, current, arrived);
|
||||
|
||||
// Map sequenceNo → latest checkpoint at that station for captions.
|
||||
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
|
||||
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group gap={8} align="center" mb={16}>
|
||||
<MapPin size={15} color={GREEN_DARK} />
|
||||
<Text fz="14px" fw={800} c={INK}>
|
||||
Where is your train
|
||||
</Text>
|
||||
<Text fz="12.5px" c={MUTED}>
|
||||
· {progress}% of the route
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Horizontal rail */}
|
||||
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
|
||||
{/* base rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 54,
|
||||
left: 16,
|
||||
right: 16,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: "#EAF0F5",
|
||||
}}
|
||||
/>
|
||||
{/* filled rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 54,
|
||||
left: 16,
|
||||
width: `calc((100% - 32px) * ${progress / 100})`,
|
||||
height: 5,
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
|
||||
transition: "width 600ms ease",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* train marker riding the filled rail */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 18,
|
||||
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
|
||||
transform: "translateX(-50%)",
|
||||
transition: "left 600ms ease",
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: arrived
|
||||
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
|
||||
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
|
||||
color: "#fff",
|
||||
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
|
||||
border: "3px solid #fff",
|
||||
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
|
||||
}}
|
||||
>
|
||||
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* station nodes */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{stations.map((s, i) => {
|
||||
const reached = arrived || (current >= 0 && i <= current);
|
||||
const isCurrent = !arrived && i === current;
|
||||
const isLast = i === stations.length - 1;
|
||||
const cp = checkpointBySeq.get(s.sequenceNo);
|
||||
return (
|
||||
<StationNode
|
||||
key={`${s.yardId}-${i}`}
|
||||
label={s.label}
|
||||
reached={reached}
|
||||
isCurrent={isCurrent}
|
||||
isEndpoint={i === 0 || isLast}
|
||||
arrivedHere={isLast && arrived}
|
||||
time={cp ? fmtTime(cp.occurredAt) : null}
|
||||
align={i === 0 ? "left" : isLast ? "right" : "center"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StationNode({
|
||||
label,
|
||||
reached,
|
||||
isCurrent,
|
||||
isEndpoint,
|
||||
arrivedHere,
|
||||
time,
|
||||
align,
|
||||
}: {
|
||||
label: string;
|
||||
reached: boolean;
|
||||
isCurrent: boolean;
|
||||
isEndpoint: boolean;
|
||||
arrivedHere: boolean;
|
||||
time: string | null;
|
||||
align: "left" | "center" | "right";
|
||||
}) {
|
||||
const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
flex: isEndpoint ? "0 0 auto" : 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 120,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: isCurrent ? 18 : 14,
|
||||
height: isCurrent ? 18 : 14,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
border: `3px solid ${color}`,
|
||||
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: isCurrent ? 7 : 5,
|
||||
height: isCurrent ? 7 : 5,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={reached ? 700 : 600}
|
||||
c={reached ? INK : "#9AA8B5"}
|
||||
mt={8}
|
||||
ta={align}
|
||||
truncate
|
||||
style={{ maxWidth: 110 }}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{time && (
|
||||
<Text fz="10px" c={MUTED} mt={1}>
|
||||
{time}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Checkpoint feed ────────────────────────────────────────────────────────────
|
||||
|
||||
function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
|
||||
// Newest first.
|
||||
const ordered = [...data.checkpoints].sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={20}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
|
||||
<Clock size={15} color={GREEN_DARK} />
|
||||
<Text fz="14px" fw={800} c={INK}>
|
||||
Journey log
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{ordered.length === 0 ? (
|
||||
<Text fz="13px" c={MUTED}>
|
||||
No checkpoints logged yet. Updates appear here as the train passes each
|
||||
station along the corridor.
|
||||
</Text>
|
||||
) : (
|
||||
<Box>
|
||||
{ordered.map((cp, i) => {
|
||||
const isLatest = i === 0;
|
||||
const last = i === ordered.length - 1;
|
||||
const Icon =
|
||||
cp.kind === Freight.TrainCheckpointKind.Arrived
|
||||
? CheckCircle2
|
||||
: cp.kind === Freight.TrainCheckpointKind.Departed
|
||||
? Flag
|
||||
: Train;
|
||||
return (
|
||||
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: isLatest ? "#ECF6F1" : "#F1F4F7",
|
||||
color: isLatest ? GREEN_DARK : "#64748B",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</Box>
|
||||
{!last && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
width: 2,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
background: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="center" wrap="wrap">
|
||||
<Text fz="13.5px" fw={700} c={INK}>
|
||||
{cp.label ?? "Checkpoint"}
|
||||
</Text>
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: "2px 9px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
background: isLatest ? "#ECF6F1" : "#F1F4F7",
|
||||
color: isLatest ? GREEN_DARK : "#475569",
|
||||
}}
|
||||
>
|
||||
{checkpointKindLabel(cp.kind)}
|
||||
</Box>
|
||||
{isLatest && (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: "2px 9px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
background: "#FEF6E6",
|
||||
color: "#B07D14",
|
||||
}}
|
||||
>
|
||||
Latest
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
{cp.note && (
|
||||
<Text fz="12.5px" c={MUTED} mt={2}>
|
||||
{cp.note}
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="11.5px" c="#9AA8B5" mt={3}>
|
||||
{fmtTime(cp.occurredAt)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty / error states ───────────────────────────────────────────────────────
|
||||
|
||||
function NotDispatchedState({
|
||||
origin,
|
||||
destination,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack align="center" gap={6} py={40} ta="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FEF6E6",
|
||||
color: ACCENT,
|
||||
}}
|
||||
>
|
||||
<PackageX size={30} />
|
||||
</Box>
|
||||
<Text fz="18px" fw={800} c={INK} mt={4}>
|
||||
Not on the rails yet
|
||||
</Text>
|
||||
<Text fz="13.5px" c={MUTED} maw={440}>
|
||||
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
|
||||
assigned to a train. Live tracking begins the moment it's dispatched and
|
||||
starts moving along the corridor.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={40} ta="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEAE7",
|
||||
color: "#C0392B",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={28} />
|
||||
</Box>
|
||||
<Text fz="16px" fw={800} c={INK}>
|
||||
Couldn't load tracking
|
||||
</Text>
|
||||
<Text fz="13px" c={MUTED}>
|
||||
Something went wrong fetching your shipment status.
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "9px 16px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#fff",
|
||||
color: INK,
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} /> Try again
|
||||
</button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// keyframes (injected once)
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
!document.getElementById("edr-tracking-kf")
|
||||
) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "edr-tracking-kf";
|
||||
style.textContent = `
|
||||
@keyframes edr-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
const { TrainScheduleStatus } = Freight;
|
||||
|
||||
export function isArrived(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
): boolean {
|
||||
return status === TrainScheduleStatus.Arrived;
|
||||
}
|
||||
|
||||
export function isDispatched(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
): boolean {
|
||||
return status === TrainScheduleStatus.Dispatched;
|
||||
}
|
||||
|
||||
/** Human label for the schedule status, from the rider's point of view. */
|
||||
export function shipmentStatusLabel(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
currentSequenceNo = -1,
|
||||
): string {
|
||||
switch (status) {
|
||||
case TrainScheduleStatus.Arrived:
|
||||
return "Arrived";
|
||||
case TrainScheduleStatus.Dispatched:
|
||||
return currentSequenceNo <= 0 ? "Departed" : "In transit";
|
||||
case TrainScheduleStatus.Scheduled:
|
||||
return "Scheduled";
|
||||
case TrainScheduleStatus.Cancelled:
|
||||
return "Cancelled";
|
||||
case TrainScheduleStatus.Draft:
|
||||
return "Preparing";
|
||||
default:
|
||||
return "Not dispatched";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 0–100 progress across the corridor, derived from how many stations the train
|
||||
* has reached. Arrived → 100. Not departed → 0.
|
||||
*/
|
||||
export function corridorProgress(
|
||||
stationCount: number,
|
||||
currentSequenceNo: number,
|
||||
arrived: boolean,
|
||||
): number {
|
||||
if (arrived) return 100;
|
||||
if (stationCount <= 1 || currentSequenceNo < 0) return 0;
|
||||
const lastSeq = stationCount - 1;
|
||||
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
|
||||
}
|
||||
|
||||
/** Caption for a checkpoint kind. */
|
||||
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
|
||||
switch (kind) {
|
||||
case Freight.TrainCheckpointKind.Departed:
|
||||
return "Departed";
|
||||
case Freight.TrainCheckpointKind.Arrived:
|
||||
return "Arrived";
|
||||
case Freight.TrainCheckpointKind.Passed:
|
||||
default:
|
||||
return "Passed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { XCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a failed or
|
||||
* cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
|
||||
* charged and sends the customer back to their bookings to retry from "Pay now".
|
||||
*/
|
||||
export default function PaymentFailurePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-9 text-destructive" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment was not completed
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your payment didn't go through and you haven't been charged. You can
|
||||
try again from your booking using "Pay now".
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a successful
|
||||
* payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
|
||||
* customer back to their bookings, where the booking reflects the paid state.
|
||||
*/
|
||||
export default function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-9 text-primary" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment successful
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thank you — your payment has been received. Your booking will be
|
||||
updated shortly and is now confirmed for scheduling.
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Go to My Bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,12 @@ export const api = {
|
||||
({ id }) => bookingsService.get(id),
|
||||
),
|
||||
|
||||
tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
|
||||
"bookings",
|
||||
"tracking",
|
||||
({ id }) => bookingsService.tracking(id),
|
||||
),
|
||||
|
||||
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
||||
"bookings",
|
||||
"create",
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface SignContractPayload {
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
statuses?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
@@ -71,6 +73,10 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post("/api/bookings", payload);
|
||||
return data.data.booking;
|
||||
|
||||
@@ -265,6 +265,56 @@ export interface IConsignment extends BaseEntity {
|
||||
destinationStation: string;
|
||||
}
|
||||
|
||||
/** One station along the train's corridor (origin → milestones → destination). */
|
||||
export interface ITrackingStation {
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** A logged checkpoint as the train passes a station. */
|
||||
export interface ITrackingCheckpoint {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string | null;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing shipment tracking payload for a single booking, derived from
|
||||
* the train schedule the booking is assigned to and the live checkpoint log.
|
||||
*
|
||||
* `hasSchedule` is false when the booking has not been assigned to a train yet
|
||||
* (still pre-dispatch) — the UI shows a "not on the rails yet" state.
|
||||
*/
|
||||
export interface IBookingTracking {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
hasSchedule: boolean;
|
||||
scheduleId: string | null;
|
||||
trainNumber: string | null;
|
||||
/** Operational status of the assigned schedule (DRAFT/SCHEDULED/DISPATCHED/ARRIVED). */
|
||||
scheduleStatus: TrainScheduleStatus | null;
|
||||
direction: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** Ordered stations forming the corridor. */
|
||||
stations: ITrackingStation[];
|
||||
/** Logged checkpoints, ordered by sequence then time. */
|
||||
checkpoints: ITrackingCheckpoint[];
|
||||
/** Highest reached station sequence (−1 = not departed). */
|
||||
currentSequenceNo: number;
|
||||
actualDepartureAt: string | null;
|
||||
actualArrivalAt: string | null;
|
||||
/** Planned departure/arrival from the schedule, used as ETA hints. */
|
||||
scheduledDepartureAt: string | null;
|
||||
scheduledArrivalAt: string | null;
|
||||
}
|
||||
|
||||
export interface IYard extends BaseEntity {
|
||||
code: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user