mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +00:00
Merge branch 'freight/feat/booking-schedule' into freight/develop
This commit is contained in:
@@ -1,28 +1,28 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
import { In, Not } from 'typeorm';
|
import { In, Not } from "typeorm";
|
||||||
|
|
||||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||||
import {
|
import {
|
||||||
CARGO_TYPES_REPOSITORY,
|
CARGO_TYPES_REPOSITORY,
|
||||||
ICargoTypesRepository,
|
ICargoTypesRepository,
|
||||||
} from '../rule-engine/interfaces/cargo-types.repository.interface';
|
} from "../rule-engine/interfaces/cargo-types.repository.interface";
|
||||||
import {
|
import {
|
||||||
CONTAINER_TYPES_REPOSITORY,
|
CONTAINER_TYPES_REPOSITORY,
|
||||||
IContainerTypesRepository,
|
IContainerTypesRepository,
|
||||||
} from '../rule-engine/interfaces/container-types.repository.interface';
|
} from "../rule-engine/interfaces/container-types.repository.interface";
|
||||||
import {
|
import {
|
||||||
IServiceTypesRepository,
|
IServiceTypesRepository,
|
||||||
SERVICE_TYPES_REPOSITORY,
|
SERVICE_TYPES_REPOSITORY,
|
||||||
} from '../rule-engine/interfaces/service-types.repository.interface';
|
} from "../rule-engine/interfaces/service-types.repository.interface";
|
||||||
import {
|
import {
|
||||||
IShippingLinesRepository,
|
IShippingLinesRepository,
|
||||||
SHIPPING_LINES_REPOSITORY,
|
SHIPPING_LINES_REPOSITORY,
|
||||||
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
|
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
|
||||||
import {
|
import {
|
||||||
IYardsRepository,
|
IYardsRepository,
|
||||||
YARDS_REPOSITORY,
|
YARDS_REPOSITORY,
|
||||||
} from '../rule-engine/interfaces/yards.repository.interface';
|
} from "../rule-engine/interfaces/yards.repository.interface";
|
||||||
import {
|
import {
|
||||||
BookingReferenceCargoTypeChildDto,
|
BookingReferenceCargoTypeChildDto,
|
||||||
BookingReferenceCargoTypeGroupDto,
|
BookingReferenceCargoTypeGroupDto,
|
||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
BookingReferenceServiceDto,
|
BookingReferenceServiceDto,
|
||||||
BookingReferenceShippingLineDto,
|
BookingReferenceShippingLineDto,
|
||||||
BookingReferenceYardDto,
|
BookingReferenceYardDto,
|
||||||
} from './dto/booking-reference-data.dto';
|
} from "./dto/booking-reference-data.dto";
|
||||||
|
|
||||||
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
|
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
||||||
|
|
||||||
export function buildCargoTypeTree(
|
export function buildCargoTypeTree(
|
||||||
rows: CargoType[],
|
rows: CargoType[],
|
||||||
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
|
|||||||
const active = rows.filter((r) => r.isActive);
|
const active = rows.filter((r) => r.isActive);
|
||||||
const parents = active
|
const parents = active
|
||||||
.filter((r) => !r.parentGroupId)
|
.filter((r) => !r.parentGroupId)
|
||||||
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
|
.sort(
|
||||||
|
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||||
|
);
|
||||||
|
|
||||||
return parents.map((parent) => {
|
return parents.map((parent) => {
|
||||||
const children = active
|
const children = active
|
||||||
.filter((r) => r.parentGroupId === parent.id)
|
.filter((r) => r.parentGroupId === parent.id)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
(a, b) =>
|
||||||
|
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||||
)
|
)
|
||||||
.map(
|
.map(
|
||||||
(child): BookingReferenceCargoTypeChildDto => ({
|
(child): BookingReferenceCargoTypeChildDto => ({
|
||||||
@@ -79,14 +82,14 @@ export function groupContainersBySize(
|
|||||||
|
|
||||||
for (const ct of active) {
|
for (const ct of active) {
|
||||||
const sizeKey =
|
const sizeKey =
|
||||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
|
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
|
||||||
const list = bySize.get(sizeKey) ?? [];
|
const list = bySize.get(sizeKey) ?? [];
|
||||||
list.push(ct);
|
list.push(ct);
|
||||||
bySize.set(sizeKey, list);
|
bySize.set(sizeKey, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortSizeKey = (key: string): number => {
|
const sortSizeKey = (key: string): number => {
|
||||||
if (key === 'other') return Number.MAX_SAFE_INTEGER;
|
if (key === "other") return Number.MAX_SAFE_INTEGER;
|
||||||
const n = parseInt(key, 10);
|
const n = parseInt(key, 10);
|
||||||
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
||||||
};
|
};
|
||||||
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
|
|||||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||||
@Inject(CARGO_TYPES_REPOSITORY)
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||||
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
code: Not(In([...LEGACY_YARD_CODES])),
|
code: Not(In([...LEGACY_YARD_CODES])),
|
||||||
},
|
},
|
||||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.containerTypesRepository.findAll({
|
this.containerTypesRepository.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.serviceTypesRepository.findAll({
|
this.serviceTypesRepository.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.shippingLinesRepository.findAll({
|
this.shippingLinesRepository.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { label: 'ASC', code: 'ASC' },
|
order: { label: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.cargoTypesRepository.findAll({
|
this.cargoTypesRepository.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
|
|||||||
containers: groupContainersBySize(containerTypes),
|
containers: groupContainersBySize(containerTypes),
|
||||||
service: serviceTypes.map(
|
service: serviceTypes.map(
|
||||||
(s): BookingReferenceServiceDto => ({
|
(s): BookingReferenceServiceDto => ({
|
||||||
id: s.id,
|
|
||||||
name: s.serviceName,
|
name: s.serviceName,
|
||||||
code: s.code,
|
...s,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
shipping_line: shippingLines.map(
|
shipping_line: shippingLines.map(
|
||||||
|
|||||||
@@ -8,87 +8,98 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
import {
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
TrainSchedulingManage,
|
||||||
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto';
|
TrainSchedulingView,
|
||||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
} from "../../common/booking-guards";
|
||||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||||
import { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
|
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||||
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
|
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
|
|
||||||
@ApiTags('train-scheduling')
|
@ApiTags("train-scheduling")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('train-scheduling')
|
@Controller("train-scheduling")
|
||||||
export class TrainSchedulingController {
|
export class TrainSchedulingController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Get('global-rules')
|
@Get("global-rules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
|
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||||
getGlobalRules() {
|
getGlobalRules() {
|
||||||
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
|
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('global-rules')
|
@Patch("global-rules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
|
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
|
||||||
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||||
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('eligible-bookings')
|
@Get("eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
|
@ApiOperation({ summary: "List eligible bookings (container and/or bulk)" })
|
||||||
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
|
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
|
||||||
return this.trainSchedulingService.getEligibleBookings(query);
|
return this.trainSchedulingService.getEligibleBookings(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-board')
|
@Get("batch-board")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
|
@ApiOperation({
|
||||||
|
summary: "Batch monitoring board: schedules with bookings grouped by state",
|
||||||
|
})
|
||||||
getBatchBoard() {
|
getBatchBoard() {
|
||||||
return this.bookingBatchService.getBatchBoard();
|
return this.bookingBatchService.getBatchBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-board/:scheduleId')
|
@Get("batch-board/:scheduleId")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
|
@ApiOperation({
|
||||||
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
summary: "Batch board detail for one schedule with EAT 3h windows",
|
||||||
|
})
|
||||||
|
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
||||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('available-locomotives')
|
@Get("available-locomotives")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List AVAILABLE locomotives at the route origin yard',
|
summary: "List AVAILABLE locomotives at the route origin yard",
|
||||||
})
|
})
|
||||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
|
return this.trainSchedulingService.getAvailableLocomotivesForRoute(
|
||||||
|
query.routeId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookable-schedules')
|
@Get("bookable-schedules")
|
||||||
@TrainSchedulingView()
|
// @TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
|
@ApiOperation({
|
||||||
|
summary: "OPEN same-route schedules a new booking can target",
|
||||||
|
})
|
||||||
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
|
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
|
||||||
return this.trainSchedulingService.getBookableSchedules(
|
return this.trainSchedulingService.getBookableSchedules(
|
||||||
query.originYardId,
|
query.originYardId,
|
||||||
@@ -96,282 +107,323 @@ export class TrainSchedulingController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('container/eligible-bookings')
|
@Get("container/eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
@ApiOperation({ summary: "List eligible container bookings" })
|
||||||
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
|
getEligibleContainerBookings(
|
||||||
|
@Query() query: GetEligibleContainerBookingsDto,
|
||||||
|
) {
|
||||||
return this.trainSchedulingService.getEligibleContainerBookings(query);
|
return this.trainSchedulingService.getEligibleContainerBookings(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bulk/eligible-bookings')
|
@Get("bulk/eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List eligible bulk bookings' })
|
@ApiOperation({ summary: "List eligible bulk bookings" })
|
||||||
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
|
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
|
||||||
return this.trainSchedulingService.getEligibleBulkBookings(query);
|
return this.trainSchedulingService.getEligibleBulkBookings(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('preview')
|
@Post("preview")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
|
@ApiOperation({ summary: "Preview a mixed-capable train schedule" })
|
||||||
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
|
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.previewTrainSchedule(dto);
|
return this.trainSchedulingService.previewTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('container/preview')
|
@Post("container/preview")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Preview a container train schedule' })
|
@ApiOperation({ summary: "Preview a container train schedule" })
|
||||||
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
|
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
|
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk/preview')
|
@Post("bulk/preview")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Preview a bulk train schedule' })
|
@ApiOperation({ summary: "Preview a bulk train schedule" })
|
||||||
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
|
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
|
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('container/schedules')
|
@Post("container/schedules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Create a container train schedule' })
|
@ApiOperation({ summary: "Create a container train schedule" })
|
||||||
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk/schedules')
|
@Post("bulk/schedules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Create a bulk train schedule' })
|
@ApiOperation({ summary: "Create a bulk train schedule" })
|
||||||
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/assign-bookings')
|
@Post("schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
|
@ApiOperation({
|
||||||
|
summary: "Assign bookings to a train schedule (mixed-capable)",
|
||||||
|
})
|
||||||
assignBookings(
|
assignBookings(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AssignBookingsDto,
|
@Body() dto: AssignBookingsDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
|
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('container/schedules/:id/assign-bookings')
|
@Post("container/schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
|
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
|
||||||
assignContainerBookings(
|
assignContainerBookings(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AssignBookingsDto,
|
@Body() dto: AssignBookingsDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
|
return this.trainSchedulingService.assignBookingsToSchedule(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
"CONTAINER",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk/schedules/:id/assign-bookings')
|
@Post("bulk/schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
|
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
|
||||||
assignBulkBookings(
|
assignBulkBookings(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AssignBookingsDto,
|
@Body() dto: AssignBookingsDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
|
return this.trainSchedulingService.assignBookingsToSchedule(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
"BULK",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('schedules/:id/bookings/:bookingId')
|
@Delete("schedules/:id/bookings/:bookingId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
|
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
|
||||||
unassignBooking(
|
unassignBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
|
return this.trainSchedulingService.unassignBooking(
|
||||||
|
id,
|
||||||
|
bookingId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('schedules/:id/wagons/:trainSetWagonId')
|
@Delete("schedules/:id/wagons/:trainSetWagonId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
|
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
|
||||||
removeWagonSlot(
|
removeWagonSlot(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
|
@Param("trainSetWagonId", ParseUUIDPipe) trainSetWagonId: string,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
|
return this.trainSchedulingService.removeTrainSetWagonSlot(
|
||||||
|
id,
|
||||||
|
trainSetWagonId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('schedules/:id/container-items/:itemId')
|
@Patch("schedules/:id/container-items/:itemId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
|
@ApiOperation({ summary: "Update a container number on a wagon slot" })
|
||||||
updateContainerItem(
|
updateContainerItem(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
@Param("itemId", ParseUUIDPipe) itemId: string,
|
||||||
@Body() dto: UpdateContainerItemDto,
|
@Body() dto: UpdateContainerItemDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('schedules/:id/unassigned-bookings')
|
@Get("schedules/:id/unassigned-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
|
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||||
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
|
getUnassignedBookings(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getUnassignedBookings(id);
|
return this.trainSchedulingService.getUnassignedBookings(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/assign-unassigned-booking')
|
@Post("schedules/:id/assign-unassigned-booking")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
|
summary:
|
||||||
|
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
|
||||||
})
|
})
|
||||||
assignUnassignedBooking(
|
assignUnassignedBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AssignUnassignedBookingDto,
|
@Body() dto: AssignUnassignedBookingDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
|
return this.trainSchedulingService.assignUnassignedBookingToWagons(
|
||||||
|
id,
|
||||||
|
dto.bookingId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('schedules/:id/composition-removals')
|
@Get("schedules/:id/composition-removals")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get removal log for a schedule' })
|
@ApiOperation({ summary: "Get removal log for a schedule" })
|
||||||
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
|
getCompositionRemovals(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getCompositionRemovals(id);
|
return this.trainSchedulingService.getCompositionRemovals(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/pin-wagons')
|
@Post("schedules/:id/pin-wagons")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
|
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
||||||
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||||
return this.trainSchedulingService.pinWagons(id, dto);
|
return this.trainSchedulingService.pinWagons(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/finalize')
|
@Post("schedules/:id/finalize")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Finalize a draft train schedule' })
|
@ApiOperation({ summary: "Finalize a draft train schedule" })
|
||||||
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.finalizeSchedule(id);
|
return this.trainSchedulingService.finalizeSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/dispatch')
|
@Post("schedules/:id/dispatch")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Dispatch a scheduled train' })
|
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||||
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
return this.trainSchedulingService.dispatchSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- batch / booking-window staff actions ----
|
// ---- batch / booking-window staff actions ----
|
||||||
|
|
||||||
@Post('schedules/:id/run-batch')
|
@Post("schedules/:id/run-batch")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
|
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
|
||||||
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
|
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
await this.bookingBatchService.fillSchedule(id);
|
await this.bookingBatchService.fillSchedule(id);
|
||||||
return this.bookingBatchService.getBatchBoardDetail(id);
|
return this.bookingBatchService.getBatchBoardDetail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/run-allocation')
|
@Post("schedules/:id/run-allocation")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
|
@ApiOperation({
|
||||||
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
|
summary: "Run wagon-level allocation for all eligible linked bookings",
|
||||||
|
})
|
||||||
|
async runAllocation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingBatchService.runWagonAllocation(id);
|
return this.bookingBatchService.runWagonAllocation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('schedules/:id/booking-window')
|
@Patch("schedules/:id/booking-window")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Open or close a schedule booking window' })
|
@ApiOperation({ summary: "Open or close a schedule booking window" })
|
||||||
async setBookingWindow(
|
async setBookingWindow(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body('status') status: 'OPEN' | 'CLOSED',
|
@Body("status") status: "OPEN" | "CLOSED",
|
||||||
) {
|
) {
|
||||||
await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN');
|
await this.trainSchedulingService.setBookingWindow(
|
||||||
|
id,
|
||||||
|
status === "CLOSED" ? "CLOSED" : "OPEN",
|
||||||
|
);
|
||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/mark-paid')
|
@Post("bookings/:bookingId/mark-paid")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' })
|
@ApiOperation({
|
||||||
async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
summary: "Staff: mark a reserved booking paid and allocate it now",
|
||||||
|
})
|
||||||
|
async markBookingPaid(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||||
await this.bookingBatchService.markPaid(bookingId);
|
await this.bookingBatchService.markPaid(bookingId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/expire')
|
@Post("bookings/:bookingId/expire")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' })
|
@ApiOperation({
|
||||||
async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
summary: "Staff: expire a reservation and free its capacity",
|
||||||
|
})
|
||||||
|
async expireBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||||
await this.bookingBatchService.expireReservation(bookingId);
|
await this.bookingBatchService.expireReservation(bookingId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/move-schedule')
|
@Post("bookings/:bookingId/move-schedule")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' })
|
@ApiOperation({
|
||||||
|
summary: "Re-point a booking to another OPEN same-route schedule",
|
||||||
|
})
|
||||||
async moveBookingSchedule(
|
async moveBookingSchedule(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
@Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string,
|
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
|
||||||
) {
|
) {
|
||||||
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
|
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('schedules/:id/checkpoints')
|
@Get("schedules/:id/checkpoints")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
|
@ApiOperation({
|
||||||
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
|
summary: "Get the tracking corridor + logged checkpoints for a train",
|
||||||
|
})
|
||||||
|
getScheduleCheckpoints(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getScheduleCheckpoints(id);
|
return this.trainSchedulingService.getScheduleCheckpoints(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/checkpoints')
|
@Post("schedules/:id/checkpoints")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
|
@ApiOperation({
|
||||||
|
summary: "Log the train passing a station (final station triggers arrival)",
|
||||||
|
})
|
||||||
recordCheckpoint(
|
recordCheckpoint(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RecordCheckpointDto,
|
@Body() dto: RecordCheckpointDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('schedules/:id/arrive')
|
@Post("schedules/:id/arrive")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
|
@ApiOperation({
|
||||||
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
summary:
|
||||||
|
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
|
||||||
|
})
|
||||||
|
arriveSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.arriveSchedule(id);
|
return this.trainSchedulingService.arriveSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('container/schedules')
|
@Get("container/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List container train schedules' })
|
@ApiOperation({ summary: "List container train schedules" })
|
||||||
getContainerTrainSchedules() {
|
getContainerTrainSchedules() {
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bulk/schedules')
|
@Get("bulk/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'List bulk train schedules' })
|
@ApiOperation({ summary: "List bulk train schedules" })
|
||||||
getBulkTrainSchedules() {
|
getBulkTrainSchedules() {
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('container/schedules/:id')
|
@Get("container/schedules/:id")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get container train schedule detail' })
|
@ApiOperation({ summary: "Get container train schedule detail" })
|
||||||
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
getContainerTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bulk/schedules/:id')
|
@Get("bulk/schedules/:id")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: 'Get bulk train schedule detail' })
|
@ApiOperation({ summary: "Get bulk train schedule detail" })
|
||||||
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
getBulkTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('container/schedules/:id/cancel')
|
@Post("container/schedules/:id/cancel")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Cancel container train schedule' })
|
@ApiOperation({ summary: "Cancel container train schedule" })
|
||||||
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk/schedules/:id/cancel')
|
@Post("bulk/schedules/:id/cancel")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Cancel bulk train schedule' })
|
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||||
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const statusColorMap: Record<string, string> = {
|
|||||||
FULLY_EXECUTED: "indigo",
|
FULLY_EXECUTED: "indigo",
|
||||||
PNR_GENERATED: "violet",
|
PNR_GENERATED: "violet",
|
||||||
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||||
|
SELECTED_FOR_BATCH: "orange",
|
||||||
|
EXPIRED: "red",
|
||||||
PAID: "green",
|
PAID: "green",
|
||||||
IN_TRANSIT: "cyan",
|
IN_TRANSIT: "cyan",
|
||||||
COMPLETED: "indigo",
|
COMPLETED: "indigo",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { Timer } from "lucide-react";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
export interface BookingPaymentCountdownCardProps {
|
||||||
|
/** ISO timestamp marking the end of the pay window. */
|
||||||
|
paymentDeadline: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Remaining {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemaining(deadlineMs: number): Remaining {
|
||||||
|
const diff = deadlineMs - Date.now();
|
||||||
|
if (diff <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.floor(diff / 1000);
|
||||||
|
return {
|
||||||
|
days: Math.floor(totalSeconds / 86400),
|
||||||
|
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||||
|
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||||
|
seconds: totalSeconds % 60,
|
||||||
|
expired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segment({ value, label }: { value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} align="center" style={{ minWidth: 56 }}>
|
||||||
|
<Text fw={700} size="2rem" style={{ lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{String(value).padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" lts="0.06em">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live countdown to the payment deadline. Ticks every second; shows an expired state past the deadline. */
|
||||||
|
export function BookingPaymentCountdownCard({ paymentDeadline }: BookingPaymentCountdownCardProps) {
|
||||||
|
const deadlineMs = new Date(paymentDeadline).getTime();
|
||||||
|
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(getRemaining(deadlineMs));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const next = getRemaining(deadlineMs);
|
||||||
|
setRemaining(next);
|
||||||
|
if (next.expired) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [deadlineMs]);
|
||||||
|
|
||||||
|
const accent = remaining.expired ? "red" : "orange";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={Timer}
|
||||||
|
title="Payment Deadline"
|
||||||
|
subtitle={
|
||||||
|
remaining.expired
|
||||||
|
? "The pay window has closed"
|
||||||
|
: "Time remaining to complete payment"
|
||||||
|
}
|
||||||
|
accent={accent}
|
||||||
|
>
|
||||||
|
{remaining.expired ? (
|
||||||
|
<Text fw={600} c="red.7">
|
||||||
|
Expired
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Group justify="center" gap="lg" wrap="nowrap">
|
||||||
|
<Segment value={remaining.days} label="Days" />
|
||||||
|
<Segment value={remaining.hours} label="Hours" />
|
||||||
|
<Segment value={remaining.minutes} label="Mins" />
|
||||||
|
<Segment value={remaining.seconds} label="Secs" />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,6 +137,8 @@ export interface BookingDetailView {
|
|||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
pnrCode?: string | null;
|
pnrCode?: string | null;
|
||||||
|
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||||
|
paymentDeadline?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
company?: BookingNamedRefView;
|
company?: BookingNamedRefView;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from "./BookingContainersCard";
|
|||||||
export * from "./BookingApprovalCard";
|
export * from "./BookingApprovalCard";
|
||||||
export * from "./BookingReviewNotesCard";
|
export * from "./BookingReviewNotesCard";
|
||||||
export * from "./BookingPaymentCard";
|
export * from "./BookingPaymentCard";
|
||||||
|
export * from "./BookingPaymentCountdownCard";
|
||||||
export * from "./BookingFactsCard";
|
export * from "./BookingFactsCard";
|
||||||
export * from "./BookingDocumentsCard";
|
export * from "./BookingDocumentsCard";
|
||||||
export * from "./BookingRequestHero";
|
export * from "./BookingRequestHero";
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Payment Verification",
|
label: "Payment Verification",
|
||||||
color: "bg-amber-50 text-amber-800 border-amber-200",
|
color: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
},
|
},
|
||||||
|
SELECTED_FOR_BATCH: {
|
||||||
|
label: "Selected for Batch",
|
||||||
|
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
label: "Expired",
|
||||||
|
color: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
},
|
||||||
PAID: {
|
PAID: {
|
||||||
label: "Paid",
|
label: "Paid",
|
||||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
@@ -241,6 +249,8 @@ export const BOOKING_LIST_TABS = [
|
|||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
"PNR_GENERATED",
|
"PNR_GENERATED",
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"EXPIRED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -270,6 +280,8 @@ export const WORKFLOW_STAGES = [
|
|||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
"PNR_GENERATED",
|
"PNR_GENERATED",
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"EXPIRED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { Container, Grid, Stack } from "@mantine/core";
|
||||||
import { Container, Stack, Grid } from "@mantine/core";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|
||||||
import {
|
import {
|
||||||
detailStyles,
|
|
||||||
type BookingDetailView,
|
|
||||||
BookingDetailToolbar,
|
|
||||||
BookingDetailHeader,
|
|
||||||
BookingLifecycleStepper,
|
|
||||||
BookingRouteCard,
|
|
||||||
BookingContainersCard,
|
|
||||||
BookingApprovalCard,
|
BookingApprovalCard,
|
||||||
BookingReviewNotesCard,
|
BookingContainersCard,
|
||||||
BookingPaymentCard,
|
BookingDetailToolbar,
|
||||||
BookingFactsCard,
|
|
||||||
BookingDocumentsCard,
|
BookingDocumentsCard,
|
||||||
|
BookingFactsCard,
|
||||||
|
BookingLifecycleStepper,
|
||||||
|
BookingPaymentCard,
|
||||||
|
BookingPaymentCountdownCard,
|
||||||
|
BookingReviewNotesCard,
|
||||||
|
BookingRouteCard,
|
||||||
|
detailStyles,
|
||||||
|
type BookingDetailView
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
|
||||||
const BookingDetailPage = () => {
|
const BookingDetailPage = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -25,8 +25,9 @@ const BookingDetailPage = () => {
|
|||||||
const booking: BookingDetailView = {
|
const booking: BookingDetailView = {
|
||||||
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
|
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
|
||||||
reference: "BKG-2026-001456",
|
reference: "BKG-2026-001456",
|
||||||
status: "IN_TRANSIT",
|
status: "SELECTED_FOR_BATCH",
|
||||||
scheduledDate: "2026-06-15",
|
scheduledDate: "2026-06-15",
|
||||||
|
paymentDeadline: "2026-06-18T17:00:00Z",
|
||||||
totalAmount: 15750.5,
|
totalAmount: 15750.5,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
paymentStatus: "PAID",
|
paymentStatus: "PAID",
|
||||||
@@ -138,6 +139,9 @@ const BookingDetailPage = () => {
|
|||||||
{/* RIGHT — summary sidebar */}
|
{/* RIGHT — summary sidebar */}
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && (
|
||||||
|
<BookingPaymentCountdownCard paymentDeadline={booking.paymentDeadline} />
|
||||||
|
)}
|
||||||
<BookingPaymentCard
|
<BookingPaymentCard
|
||||||
totalAmount={booking.totalAmount}
|
totalAmount={booking.totalAmount}
|
||||||
currency={booking.paymentCurrency}
|
currency={booking.paymentCurrency}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
"@edr/tsconfig": "workspace:*",
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@hookform/devtools": "^4.4.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
|||||||
@@ -75,12 +75,10 @@ function RequireAuth() {
|
|||||||
* Only redirects on a confirmed "no company" response — never on a
|
* Only redirects on a confirmed "no company" response — never on a
|
||||||
* transient query error.
|
* transient query error.
|
||||||
*/
|
*/
|
||||||
function RequireCompany() {
|
function RequireCompany({path}: {path: string}) {
|
||||||
const { customerQuery } = useAuth();
|
const { customerQuery } = useAuth();
|
||||||
|
|
||||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||||
if (customerQuery.isSuccess && !customerQuery.data)
|
|
||||||
return <Navigate to="/onboarding" replace />;
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +173,7 @@ const App = () => {
|
|||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<RequireCompany />}>
|
<Route element={<RequireCompany path={location.pathname} />}>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|||||||
@@ -96,4 +96,8 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
TRAIN_SCHEDULING: {
|
||||||
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
OtpResponse,
|
||||||
SignupPayload,
|
SignupPayload,
|
||||||
SignupResponse,
|
SignupResponse,
|
||||||
OtpResponse,
|
|
||||||
} from "@/types/auth";
|
} from "@/types/auth";
|
||||||
import type { Result } from "@/utils/result";
|
import type { Result } from "@/utils/result";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
import { useEffect } from "react";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days: number) {
|
function setCookie(name: string, value: string, days: number) {
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
@@ -32,6 +31,8 @@ const useAuth = () => {
|
|||||||
api.auth.getMyInfo.queryOptions({
|
api.auth.getMyInfo.queryOptions({
|
||||||
enabled: hasToken,
|
enabled: hasToken,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 10 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
}),
|
}),
|
||||||
@@ -46,13 +47,6 @@ const useAuth = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authQuery.isError) {
|
|
||||||
queryClient.clear();
|
|
||||||
localStorage.clear();
|
|
||||||
}
|
|
||||||
}, [authQuery.isError, queryClient]);
|
|
||||||
|
|
||||||
const isPending = authQuery.isPending && hasToken;
|
const isPending = authQuery.isPending && hasToken;
|
||||||
const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError;
|
const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Box, Grid, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +22,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
@@ -30,7 +38,12 @@ const cv = (token: string) => {
|
|||||||
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ["DRAFT", "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT"];
|
const ACTIVE_STATUSES = [
|
||||||
|
"DRAFT",
|
||||||
|
"SUBMITTED",
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"IN_TRANSIT",
|
||||||
|
];
|
||||||
|
|
||||||
interface StageConfig {
|
interface StageConfig {
|
||||||
stage: number;
|
stage: number;
|
||||||
@@ -43,7 +56,11 @@ interface StageConfig {
|
|||||||
badgeBg: string;
|
badgeBg: string;
|
||||||
badgeText: string;
|
badgeText: string;
|
||||||
badgeDot: string;
|
badgeDot: string;
|
||||||
action: { label: string; kind: "dark" | "amber" | "outline"; icon?: LucideIcon };
|
action: {
|
||||||
|
label: string;
|
||||||
|
kind: "dark" | "amber" | "outline";
|
||||||
|
icon?: LucideIcon;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, StageConfig> = {
|
const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||||
@@ -73,19 +90,162 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
badgeDot: "edr-blue-dot",
|
badgeDot: "edr-blue-dot",
|
||||||
action: { label: "View", kind: "outline" },
|
action: { label: "View", kind: "outline" },
|
||||||
},
|
},
|
||||||
|
CHANGES_REQUESTED: {
|
||||||
|
stage: 1,
|
||||||
|
icon: FilePen,
|
||||||
|
iconColor: "edr-amber-text",
|
||||||
|
tile: "edr-amber-soft",
|
||||||
|
hint: "Changes requested · please update",
|
||||||
|
step: "edr-accent",
|
||||||
|
badgeLabel: "Revise",
|
||||||
|
badgeBg: "edr-amber-soft",
|
||||||
|
badgeText: "edr-amber-text",
|
||||||
|
badgeDot: "edr-accent",
|
||||||
|
action: { label: "Update", kind: "dark" },
|
||||||
|
},
|
||||||
PENDING_APPROVAL: {
|
PENDING_APPROVAL: {
|
||||||
|
stage: 2,
|
||||||
|
icon: FileCheck2,
|
||||||
|
iconColor: "edr-blue",
|
||||||
|
tile: "edr-blue-soft",
|
||||||
|
hint: "Pending internal approval",
|
||||||
|
step: "edr-blue-dot",
|
||||||
|
badgeLabel: "Pending",
|
||||||
|
badgeBg: "edr-blue-soft",
|
||||||
|
badgeText: "edr-blue",
|
||||||
|
badgeDot: "edr-blue-dot",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
APPROVED_PENDING_SIGNATURE: {
|
||||||
|
stage: 2,
|
||||||
|
icon: FileCheck2,
|
||||||
|
iconColor: "edr-blue",
|
||||||
|
tile: "edr-blue-soft",
|
||||||
|
hint: "Approved · awaiting signature",
|
||||||
|
step: "edr-blue-dot",
|
||||||
|
badgeLabel: "For Signature",
|
||||||
|
badgeBg: "edr-blue-soft",
|
||||||
|
badgeText: "edr-blue",
|
||||||
|
badgeDot: "edr-blue-dot",
|
||||||
|
action: { label: "Review", kind: "outline" },
|
||||||
|
},
|
||||||
|
APPROVED: {
|
||||||
|
stage: 2,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Quote approved · ready to sign",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Approved",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
CONTRACT_READY: {
|
||||||
|
stage: 2,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Contract ready · awaiting signature",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Contract Ready",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "Review", kind: "outline" },
|
||||||
|
},
|
||||||
|
SIGNED_CUSTOMER: {
|
||||||
|
stage: 3,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Signed by customer · internal processing",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Signed",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
FULLY_EXECUTED: {
|
||||||
|
stage: 3,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Fully executed · generating PNR",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Executed",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
PNR_GENERATED: {
|
||||||
|
stage: 3,
|
||||||
|
icon: FileCheck2,
|
||||||
|
iconColor: "edr-blue",
|
||||||
|
tile: "edr-blue-soft",
|
||||||
|
hint: "PNR generated · awaiting payment verification",
|
||||||
|
step: "edr-blue-dot",
|
||||||
|
badgeLabel: "PNR Ready",
|
||||||
|
badgeBg: "edr-blue-soft",
|
||||||
|
badgeText: "edr-blue",
|
||||||
|
badgeDot: "edr-blue-dot",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||||
|
stage: 2,
|
||||||
|
icon: Clock3,
|
||||||
|
iconColor: "edr-amber-text",
|
||||||
|
tile: "edr-amber-soft",
|
||||||
|
hint: "Verifying payment · please wait",
|
||||||
|
step: "edr-accent",
|
||||||
|
badgeLabel: "Verifying",
|
||||||
|
badgeBg: "edr-amber-soft",
|
||||||
|
badgeText: "edr-amber-text",
|
||||||
|
badgeDot: "edr-accent",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
SELECTED_FOR_BATCH: {
|
||||||
stage: 2,
|
stage: 2,
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
iconColor: "edr-amber-text",
|
iconColor: "edr-amber-text",
|
||||||
tile: "edr-amber-soft",
|
tile: "edr-amber-soft",
|
||||||
hint: "Quote ready · awaiting payment",
|
hint: "Selected for batch · payment due within 1 hour",
|
||||||
step: "edr-accent",
|
step: "edr-accent",
|
||||||
badgeLabel: "Awaiting Payment",
|
badgeLabel: "Pay Now",
|
||||||
badgeBg: "edr-amber-soft",
|
badgeBg: "edr-amber-soft",
|
||||||
badgeText: "edr-amber-text",
|
badgeText: "edr-amber-text",
|
||||||
badgeDot: "edr-accent",
|
badgeDot: "edr-accent",
|
||||||
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
|
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
|
||||||
},
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
stage: 1,
|
||||||
|
icon: Clock3,
|
||||||
|
iconColor: "edr-red",
|
||||||
|
tile: "edr-red-soft",
|
||||||
|
hint: "Payment window expired · contact support",
|
||||||
|
step: "edr-red",
|
||||||
|
badgeLabel: "Expired",
|
||||||
|
badgeBg: "edr-red-soft",
|
||||||
|
badgeText: "edr-red",
|
||||||
|
badgeDot: "edr-red",
|
||||||
|
action: { label: "Contact", kind: "outline" },
|
||||||
|
},
|
||||||
|
PAID: {
|
||||||
|
stage: 3,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Payment received · awaiting dispatch",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Paid",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
IN_TRANSIT: {
|
IN_TRANSIT: {
|
||||||
stage: 3,
|
stage: 3,
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
@@ -100,6 +260,19 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
action: { label: "Track", kind: "outline", icon: MapPin },
|
action: { label: "Track", kind: "outline", icon: MapPin },
|
||||||
},
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
|
stage: 4,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-slate",
|
||||||
|
tile: "edr-slate-soft2",
|
||||||
|
hint: "Completed · awaiting delivery",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Completed",
|
||||||
|
badgeBg: "edr-slate-soft2",
|
||||||
|
badgeText: "edr-slate",
|
||||||
|
badgeDot: "edr-step",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
DELIVERED: {
|
||||||
stage: 4,
|
stage: 4,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
iconColor: "edr-slate",
|
iconColor: "edr-slate",
|
||||||
@@ -130,12 +303,38 @@ const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
icon: FilePen,
|
icon: FilePen,
|
||||||
iconColor: "edr-red",
|
iconColor: "edr-red",
|
||||||
tile: "edr-red-soft",
|
tile: "edr-red-soft",
|
||||||
hint: "Rejected",
|
hint: "Rejected · contact support",
|
||||||
step: "edr-red",
|
step: "edr-red",
|
||||||
badgeLabel: "Rejected",
|
badgeLabel: "Rejected",
|
||||||
badgeBg: "edr-red-soft",
|
badgeBg: "edr-red-soft",
|
||||||
badgeText: "edr-red",
|
badgeText: "edr-red",
|
||||||
badgeDot: "edr-red",
|
badgeDot: "edr-red",
|
||||||
|
action: { label: "Contact", kind: "outline" },
|
||||||
|
},
|
||||||
|
PENDING_CONSOLIDATION: {
|
||||||
|
stage: 3,
|
||||||
|
icon: Clock3,
|
||||||
|
iconColor: "edr-blue",
|
||||||
|
tile: "edr-blue-soft",
|
||||||
|
hint: "Awaiting consolidation",
|
||||||
|
step: "edr-blue-dot",
|
||||||
|
badgeLabel: "Consolidating",
|
||||||
|
badgeBg: "edr-blue-soft",
|
||||||
|
badgeText: "edr-blue",
|
||||||
|
badgeDot: "edr-blue-dot",
|
||||||
|
action: { label: "View", kind: "outline" },
|
||||||
|
},
|
||||||
|
CONSOLIDATED: {
|
||||||
|
stage: 3,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Consolidated · ready for dispatch",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Consolidated",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
action: { label: "View", kind: "outline" },
|
action: { label: "View", kind: "outline" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -146,7 +345,10 @@ const ACTION_PROPS: Record<string, { bg: string; c: string; bd?: string }> = {
|
|||||||
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
|
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const INVOICE_BADGE: Record<InvoiceStatus, { label: string; bg: string; text: string }> = {
|
const INVOICE_BADGE: Record<
|
||||||
|
InvoiceStatus,
|
||||||
|
{ label: string; bg: string; text: string }
|
||||||
|
> = {
|
||||||
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||||
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||||
@@ -157,108 +359,181 @@ const INVOICE_BADGE: Record<InvoiceStatus, { label: string; bg: string; text: st
|
|||||||
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
||||||
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
|
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
|
||||||
|
|
||||||
type Tab = "all" | "needs" | "completed";
|
|
||||||
const NEEDS_ACTION = ["DRAFT", "PENDING_APPROVAL"];
|
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
const { user, customer } = useAuth();
|
const { user, customer } = useAuth();
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<Tab>("all");
|
|
||||||
|
|
||||||
const bookingsQuery = useQuery(
|
const bookingsQuery = useQuery(
|
||||||
api.bookings.list.queryOptions({ input: { sortBy: "createdAt", sortOrder: "DESC" } }),
|
api.bookings.list.queryOptions({
|
||||||
|
input: { sortBy: "createdAt", sortOrder: "DESC" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allBookings = bookingsQuery.data?.items ?? [];
|
const allBookings = bookingsQuery.data?.items ?? [];
|
||||||
const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status));
|
const activeBookings = allBookings.filter((b) =>
|
||||||
|
ACTIVE_STATUSES.includes(b.status),
|
||||||
|
);
|
||||||
|
|
||||||
const visibleBookings = allBookings
|
const visibleBookings = allBookings;
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
);
|
);
|
||||||
const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0);
|
const totalOutstanding = outstandingInvoices.reduce(
|
||||||
const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12;
|
(sum, inv) => sum + inv.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const deliveredCount =
|
||||||
|
myShipments.filter((s) => s.status === "Delivered").length || 12;
|
||||||
|
|
||||||
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||||
const companyName = (customer as any)?.companyName ?? displayName;
|
const companyName = (customer as any)?.companyName ?? displayName;
|
||||||
|
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? "Good morning," : hour < 18 ? "Good afternoon," : "Good evening,";
|
const greeting =
|
||||||
|
hour < 12
|
||||||
|
? "Good morning,"
|
||||||
|
: hour < 18
|
||||||
|
? "Good afternoon,"
|
||||||
|
: "Good evening,";
|
||||||
const recentInvoices = myInvoices.slice(0, 3);
|
const recentInvoices = myInvoices.slice(0, 3);
|
||||||
const maxVolume = Math.max(...VOLUME_DATA);
|
const maxVolume = Math.max(...VOLUME_DATA);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
{/* ── Hello Row ─────────────────────────────────────────────────────── */}
|
{/* ── Hello Row ─────────────────────────────────────────────────────── */}
|
||||||
<Group
|
<Group justify="space-between" align="center" gap="md">
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
gap="md"
|
|
||||||
className="!flex-col !items-stretch md:!flex-row md:!items-center"
|
|
||||||
>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" c="edr-muted">{greeting}</Text>
|
<Text size="sm" c="edr-muted">
|
||||||
|
{greeting}
|
||||||
|
</Text>
|
||||||
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
|
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
|
||||||
{companyName} 👋
|
{companyName} 👋
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Book a shipment CTA */}
|
{/* Book a shipment CTA */}
|
||||||
<Group
|
<Link to="/bookings/new">
|
||||||
component={Link as any}
|
<Group
|
||||||
to="/bookings/new"
|
gap={14}
|
||||||
gap={14}
|
align="center"
|
||||||
align="center"
|
wrap="nowrap"
|
||||||
wrap="nowrap"
|
bg="edr-green"
|
||||||
bg="edr-green"
|
px={18}
|
||||||
px={18}
|
py={14}
|
||||||
py={14}
|
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
|
||||||
className="w-full md:!w-[240px] rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
|
>
|
||||||
>
|
|
||||||
|
|
||||||
<Truck size={22} color="#fff" />
|
<Truck size={22} color="#fff" />
|
||||||
|
|
||||||
<Box className="min-w-0 flex-1">
|
<Box className="min-w-0 flex-1">
|
||||||
<Text fz={14} fw={700} c="white" lh={1.3}>Book a shipment</Text>
|
<Text fz={14} fw={700} c="white" lh={1.3}>
|
||||||
</Box>
|
Book a shipment
|
||||||
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
|
</Text>
|
||||||
<ArrowRight size={18} color={cv("edr-green.7")} />
|
</Box>
|
||||||
</Box>
|
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
|
||||||
</Group>
|
<ArrowRight size={18} color={cv("edr-green.7")} />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{!customer&& (
|
||||||
|
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Box className="flex-1">
|
||||||
|
<Text fz={15} fw={700} c="edr-text" mb={6}>
|
||||||
|
Setup your Company Profile
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="edr-muted" mb={12}>
|
||||||
|
Complete your company information to unlock all features and start booking shipments.
|
||||||
|
</Text>
|
||||||
|
<Link to="/onboarding" className="no-underline">
|
||||||
|
<Group gap={8} align="center" className="w-fit">
|
||||||
|
<Text fz={13} fw={600} c="edr-green.7">
|
||||||
|
Complete Setup
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={16} color={cv("edr-green.7")} />
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
<Box className="hidden shrink-0 sm:block">
|
||||||
|
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
|
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
|
||||||
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
|
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
|
||||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
|
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
|
||||||
<StatKpi icon={Truck} label="Active Shipments" value={activeBookings.length.toString()} delta="+2 this week" deltaColor="edr-green.7" />
|
<StatKpi
|
||||||
<StatKpi icon={Clock3} label="Awaiting Payment" value={outstandingInvoices.length.toString()} delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`} deltaColor="edr-amber-text" divider />
|
icon={Truck}
|
||||||
<StatKpi icon={CheckCircle2} label="Delivered (May)" value={deliveredCount.toString()} delta="96% on-time" deltaColor="edr-muted" divider />
|
label="Active Shipments"
|
||||||
<StatKpi icon={Wallet} label="Spend YTD" value="ETB 1.24M" delta="+16% YoY" deltaColor="edr-green.7" divider />
|
value={activeBookings.length.toString()}
|
||||||
|
delta="+2 this week"
|
||||||
|
deltaColor="edr-green.7"
|
||||||
|
/>
|
||||||
|
<StatKpi
|
||||||
|
icon={Clock3}
|
||||||
|
label="Awaiting Payment"
|
||||||
|
value={outstandingInvoices.length.toString()}
|
||||||
|
delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`}
|
||||||
|
deltaColor="edr-amber-text"
|
||||||
|
divider
|
||||||
|
/>
|
||||||
|
<StatKpi
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Delivered (May)"
|
||||||
|
value={deliveredCount.toString()}
|
||||||
|
delta="96% on-time"
|
||||||
|
deltaColor="edr-muted"
|
||||||
|
divider
|
||||||
|
/>
|
||||||
|
<StatKpi
|
||||||
|
icon={Wallet}
|
||||||
|
label="Spend YTD"
|
||||||
|
value="ETB 1.24M"
|
||||||
|
delta="+16% YoY"
|
||||||
|
deltaColor="edr-green.7"
|
||||||
|
divider
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
|
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
|
||||||
<Grid align="stretch">
|
<Grid align="stretch">
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Card className="h-full" padding={28}>
|
<Card className="h-full" padding={28}>
|
||||||
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={19} fw={800} c="edr-text">My Shipments</Text>
|
<Text fz={19} fw={800} c="edr-text">
|
||||||
<Text fz={13} c="edr-muted">From draft to delivery — every booking in one place</Text>
|
My Shipments
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
From draft to delivery — every booking in one place
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{bookingsQuery.isPending ? (
|
{bookingsQuery.isPending ? (
|
||||||
<Stack gap={6}>{[1, 2, 3, 4].map((i) => <Skeleton key={i} height={64} radius="md" />)}</Stack>
|
<Stack gap={6}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} height={64} radius="md" />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
) : visibleBookings.length === 0 ? (
|
) : visibleBookings.length === 0 ? (
|
||||||
<EmptyState message="No bookings in this view." />
|
<EmptyState message="No bookings in this view." />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{visibleBookings.map((booking, i) => (
|
{visibleBookings.map((booking, i) => (
|
||||||
<BookingRow key={booking.id} booking={booking} last={i === visibleBookings.length - 1} onClick={() => navigate(`/bookings/${booking.id}`)} />
|
<BookingRow
|
||||||
|
key={booking.id}
|
||||||
|
booking={booking}
|
||||||
|
last={i === visibleBookings.length - 1}
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -269,22 +544,48 @@ export default function MyPortalPage() {
|
|||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Card className="h-full" padding={24}>
|
<Card className="h-full" padding={24}>
|
||||||
<Group justify="space-between" align="center" mb={16}>
|
<Group justify="space-between" align="center" mb={16}>
|
||||||
<Text fz={17} fw={700} c="edr-text">Invoices</Text>
|
<Text fz={17} fw={700} c="edr-text">
|
||||||
<Group component={Link as any} to="/billing" gap={3} align="center" className="no-underline">
|
Invoices
|
||||||
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
|
</Text>
|
||||||
<ChevronRight size={15} color={cv("edr-green.7")} />
|
<Link to="/billing">
|
||||||
</Group>
|
<Group gap={3} align="center" className="no-underline">
|
||||||
|
<Text fz={13} fw={600} c="edr-green.7">
|
||||||
|
View all
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={15} color={cv("edr-green.7")} />
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Outstanding card */}
|
{/* Outstanding card */}
|
||||||
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
|
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
|
||||||
<Text fz={12} fw={600} c="edr-amber-text">Outstanding balance</Text>
|
<Text fz={12} fw={600} c="edr-amber-text">
|
||||||
<Text fz={24} fw={800} mt={4} c="edr-text">{formatCurrency(totalOutstanding || 377500, "ETB")}</Text>
|
Outstanding balance
|
||||||
<Group justify="space-between" align="center" mt={8} wrap="nowrap">
|
</Text>
|
||||||
<Text fz={12} c="edr-amber-text">{outstandingInvoices.length || 2} invoices unpaid</Text>
|
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||||
<Group gap={5} align="center" px={14} py={8} bg="edr-accent" className="cursor-pointer rounded-[9px]">
|
{formatCurrency(totalOutstanding || 377500, "ETB")}
|
||||||
|
</Text>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
mt={8}
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
<Text fz={12} c="edr-amber-text">
|
||||||
|
{outstandingInvoices.length || 2} invoices unpaid
|
||||||
|
</Text>
|
||||||
|
<Group
|
||||||
|
gap={5}
|
||||||
|
align="center"
|
||||||
|
px={14}
|
||||||
|
py={8}
|
||||||
|
bg="edr-accent"
|
||||||
|
className="cursor-pointer rounded-[9px]"
|
||||||
|
>
|
||||||
<Zap size={15} color="#fff" />
|
<Zap size={15} color="#fff" />
|
||||||
<Text fz={13} fw={700} c="white">Pay all</Text>
|
<Text fz={13} fw={700} c="white">
|
||||||
|
Pay all
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -302,26 +603,53 @@ export default function MyPortalPage() {
|
|||||||
: invoice.status === "Overdue"
|
: invoice.status === "Overdue"
|
||||||
? "Overdue 3 days"
|
? "Overdue 3 days"
|
||||||
: `Due ${invoice.dueDate}`;
|
: `Due ${invoice.dueDate}`;
|
||||||
const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
const DueIcon =
|
||||||
const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted");
|
invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
||||||
|
const dueIconColor =
|
||||||
|
invoice.status === "Paid"
|
||||||
|
? cv("edr-green.5")
|
||||||
|
: cv("edr-muted");
|
||||||
return (
|
return (
|
||||||
<Box key={invoice.id}>
|
<Box key={invoice.id}>
|
||||||
{i > 0 && <Box h={1} bg="edr-divider" />}
|
{i > 0 && <Box h={1} bg="edr-divider" />}
|
||||||
<Stack gap={8} py={10}>
|
<Stack gap={8} py={10}>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={13} fw={700} c="edr-text">{invoice.number}</Text>
|
<Text fz={13} fw={700} c="edr-text">
|
||||||
<Text fz={11} c="edr-muted">{invoice.bookingReference}</Text>
|
{invoice.number}
|
||||||
|
</Text>
|
||||||
|
<Text fz={11} c="edr-muted">
|
||||||
|
{invoice.bookingReference}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={14} fw={700} c="edr-text">{formatCurrency(invoice.amount, invoice.currency)}</Text>
|
<Text fz={14} fw={700} c="edr-text">
|
||||||
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
<Group gap={5} align="center">
|
<Group gap={5} align="center">
|
||||||
<DueIcon size={13} color={dueIconColor} />
|
<DueIcon size={13} color={dueIconColor} />
|
||||||
<Text fz={12} c="edr-muted">{dueText}</Text>
|
<Text fz={12} c="edr-muted">
|
||||||
|
{dueText}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Box bg={badge.bg} px={10} py={4} className="rounded-full">
|
<Box
|
||||||
<Text fz={11} fw={700} c={badge.text}>{badge.label}</Text>
|
bg={badge.bg}
|
||||||
|
px={10}
|
||||||
|
py={4}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Text fz={11} fw={700} c={badge.text}>
|
||||||
|
{badge.label}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -335,27 +663,40 @@ export default function MyPortalPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
|
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
|
||||||
<Grid gutter={20} align="stretch">
|
<Grid align="stretch">
|
||||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||||
<Card className="h-full" padding={24}>
|
<Card className="h-full" padding={24}>
|
||||||
<Text fz={17} fw={700} c="edr-text">Freight Volume</Text>
|
<Text fz={17} fw={700} c="edr-text">
|
||||||
|
Freight Volume
|
||||||
|
</Text>
|
||||||
<Group gap={10} align="baseline" mt={4} mb={22}>
|
<Group gap={10} align="baseline" mt={4} mb={22}>
|
||||||
<Text fz={26} fw={800} c="edr-text">4,180 t</Text>
|
<Text fz={26} fw={800} c="edr-text">
|
||||||
<Text fz={13} c="edr-muted">ETB 1.24M</Text>
|
4,180 t
|
||||||
<Text fz={12} fw={700} c="edr-green.7">+16% YTD</Text>
|
</Text>
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
ETB 1.24M
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} fw={700} c="edr-green.7">
|
||||||
|
+16% YTD
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||||
{VOLUME_DATA.map((val, i) => {
|
{VOLUME_DATA.map((val, i) => {
|
||||||
const isLast = i === VOLUME_DATA.length - 1;
|
const isLast = i === VOLUME_DATA.length - 1;
|
||||||
return (
|
return (
|
||||||
<Box key={i} className="flex flex-1 flex-col items-center gap-2">
|
<Box
|
||||||
|
key={i}
|
||||||
|
className="flex flex-1 flex-col items-center gap-2"
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
bg={isLast ? "edr-green" : "edr-soft"}
|
bg={isLast ? "edr-green" : "edr-soft"}
|
||||||
bd={isLast ? undefined : "1px solid edr-border"}
|
bd={isLast ? undefined : "1px solid edr-border"}
|
||||||
h={Math.round((val / maxVolume) * 86)}
|
h={Math.round((val / maxVolume) * 86)}
|
||||||
className="w-full rounded-t-md"
|
className="w-full rounded-t-md"
|
||||||
/>
|
/>
|
||||||
<Text fz={11} c="edr-muted">{MONTHS[i]}</Text>
|
<Text fz={11} c="edr-muted">
|
||||||
|
{MONTHS[i]}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -366,21 +707,35 @@ export default function MyPortalPage() {
|
|||||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||||
<Card className="h-full" padding={24}>
|
<Card className="h-full" padding={24}>
|
||||||
<Group justify="space-between" align="center" mb={16}>
|
<Group justify="space-between" align="center" mb={16}>
|
||||||
<Text fz={17} fw={700} c="edr-text">Recent Activity</Text>
|
<Text fz={17} fw={700} c="edr-text">
|
||||||
<Group component={Link as any} to="/bookings" gap={3} align="center" className="no-underline">
|
Recent Activity
|
||||||
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
|
</Text>
|
||||||
<ChevronRight size={15} color={cv("edr-green.7")} />
|
<Link to="/bookings">
|
||||||
</Group>
|
<Group gap={3} align="center" className="no-underline">
|
||||||
|
<Text fz={13} fw={600} c="edr-green.7">
|
||||||
|
View all
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={15} color={cv("edr-green.7")} />
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{bookingsQuery.isPending ? (
|
{bookingsQuery.isPending ? (
|
||||||
<Stack gap={10}>{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} height={44} radius="md" />)}</Stack>
|
<Stack gap={10}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} height={44} radius="md" />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
) : allBookings.length === 0 ? (
|
) : allBookings.length === 0 ? (
|
||||||
<EmptyState message="No recent activity." />
|
<EmptyState message="No recent activity." />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{allBookings.slice(0, 6).map((booking) => (
|
{allBookings.slice(0, 6).map((booking) => (
|
||||||
<ActivityRow key={booking.id} booking={booking} onClick={() => navigate(`/bookings/${booking.id}`)} />
|
<ActivityRow
|
||||||
|
key={booking.id}
|
||||||
|
booking={booking}
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -403,7 +758,10 @@ function Card({
|
|||||||
padding?: number;
|
padding?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box p={padding} className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}>
|
<Box
|
||||||
|
p={padding}
|
||||||
|
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -425,14 +783,25 @@ function StatKpi({
|
|||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box px={4} className={divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined}>
|
<Box
|
||||||
|
px={4}
|
||||||
|
className={
|
||||||
|
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Group gap={6} align="center" mb={7} wrap="nowrap">
|
<Group gap={6} align="center" mb={7} wrap="nowrap">
|
||||||
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
|
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
|
||||||
<Text fz={12} fw={600} c="edr-muted" truncate>{label}</Text>
|
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={8} align="flex-end" wrap="nowrap">
|
<Group gap={8} align="flex-end" wrap="nowrap">
|
||||||
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>{value}</Text>
|
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
|
||||||
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>{delta}</Text>
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
|
||||||
|
{delta}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -446,9 +815,26 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
|
|||||||
const active = i === stage;
|
const active = i === stage;
|
||||||
const size = active ? 12 : done ? 9 : 8;
|
const size = active ? 12 : done ? 9 : 8;
|
||||||
return (
|
return (
|
||||||
<Group key={i} gap={0} align="center" wrap="nowrap" className={i < 4 ? "flex-1" : undefined}>
|
<Group
|
||||||
<Box w={size} h={size} bg={done || active ? color : "edr-step-idle"} className="shrink-0 rounded-full" />
|
key={i}
|
||||||
{i < 4 && <Box h={3} bg={i < stage ? color : "edr-conn-idle"} className="flex-1 rounded-full" />}
|
gap={0}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
className={i < 4 ? "flex-1" : undefined}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w={size}
|
||||||
|
h={size}
|
||||||
|
bg={done || active ? color : "edr-step-idle"}
|
||||||
|
className="shrink-0 rounded-full"
|
||||||
|
/>
|
||||||
|
{i < 4 && (
|
||||||
|
<Box
|
||||||
|
h={3}
|
||||||
|
bg={i < stage ? color : "edr-conn-idle"}
|
||||||
|
className="flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -456,43 +842,97 @@ function Stepper({ stage, color }: { stage: number; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; onClick: () => void }) {
|
function BookingRow({
|
||||||
|
booking,
|
||||||
|
last,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
booking: any;
|
||||||
|
last: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
||||||
const Icon = cfg.icon;
|
const Icon = cfg.icon;
|
||||||
const AIcon = cfg.action.icon;
|
const AIcon = cfg.action.icon;
|
||||||
const ap = ACTION_PROPS[cfg.action.kind];
|
const ap = ACTION_PROPS[cfg.action.kind];
|
||||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
const dest =
|
||||||
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
const commodity =
|
const commodity =
|
||||||
(typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ??
|
(typeof booking.cargoType === "string"
|
||||||
|
? booking.cargoType
|
||||||
|
: booking.cargoType?.name) ??
|
||||||
booking.commodity ??
|
booking.commodity ??
|
||||||
"Freight";
|
"Freight";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={last ? undefined : "border-b border-edr-divider"}>
|
<Box className={last ? undefined : "border-b border-edr-divider"}>
|
||||||
<Group gap={16} align="center" wrap="nowrap" py={14} px={4} className="cursor-pointer" onClick={onClick}>
|
<Group
|
||||||
<Box w={46} h={46} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-xl">
|
gap={16}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
py={14}
|
||||||
|
px={4}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w={46}
|
||||||
|
h={46}
|
||||||
|
bg={cfg.tile}
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-xl"
|
||||||
|
>
|
||||||
<Icon size={22} color={cv(cfg.iconColor)} />
|
<Icon size={22} color={cv(cfg.iconColor)} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
|
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
|
||||||
<Text fz={15} fw={700} c="edr-text" truncate>{booking.reference}</Text>
|
<Text fz={15} fw={700} c="edr-text" truncate>
|
||||||
<Text fz={12} c="edr-muted" truncate>{commodity} · {origin} → {dest}</Text>
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="edr-muted" truncate>
|
||||||
|
{commodity} · {origin} → {dest}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
|
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
|
||||||
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>{cfg.hint}</Text>
|
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
|
||||||
|
{cfg.hint}
|
||||||
|
</Text>
|
||||||
<Stepper stage={cfg.stage} color={cfg.step} />
|
<Stepper stage={cfg.stage} color={cfg.step} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack gap={9} align="flex-end" className="shrink-0">
|
<Stack gap={9} align="flex-end" className="shrink-0">
|
||||||
<Group gap={6} align="center" px={11} py={5} bg={cfg.badgeBg} className="rounded-full">
|
<Group
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
px={11}
|
||||||
|
py={5}
|
||||||
|
bg={cfg.badgeBg}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
|
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
|
||||||
<Text fz={11} fw={700} c={cfg.badgeText}>{cfg.badgeLabel}</Text>
|
<Text fz={11} fw={700} c={cfg.badgeText}>
|
||||||
|
{cfg.badgeLabel}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={5} align="center" px={15} py={8} bg={ap.bg} bd={ap.bd} className="cursor-pointer rounded-[9px]">
|
<Group
|
||||||
<Text fz={13} fw={700} c={ap.c}>{cfg.action.label}</Text>
|
gap={5}
|
||||||
{AIcon && <AIcon size={15} color={ap.c === "white" ? "#fff" : cv("edr-text")} />}
|
align="center"
|
||||||
|
px={15}
|
||||||
|
py={8}
|
||||||
|
bg={ap.bg}
|
||||||
|
bd={ap.bd}
|
||||||
|
className="cursor-pointer rounded-[9px]"
|
||||||
|
>
|
||||||
|
<Text fz={13} fw={700} c={ap.c}>
|
||||||
|
{cfg.action.label}
|
||||||
|
</Text>
|
||||||
|
{AIcon && (
|
||||||
|
<AIcon
|
||||||
|
size={15}
|
||||||
|
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -500,7 +940,13 @@ function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }) {
|
function ActivityRow({
|
||||||
|
booking,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
booking: any;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
||||||
const Icon = cfg.icon;
|
const Icon = cfg.icon;
|
||||||
const verb =
|
const verb =
|
||||||
@@ -514,26 +960,49 @@ function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }
|
|||||||
? "submitted for review"
|
? "submitted for review"
|
||||||
: "created";
|
: "created";
|
||||||
return (
|
return (
|
||||||
<Group gap={12} align="center" wrap="nowrap" py={9} className="cursor-pointer" onClick={onClick}>
|
<Group
|
||||||
<Box w={36} h={36} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-[10px]">
|
gap={12}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
py={9}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w={36}
|
||||||
|
h={36}
|
||||||
|
bg={cfg.tile}
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-[10px]"
|
||||||
|
>
|
||||||
<Icon size={17} color={cv(cfg.iconColor)} />
|
<Icon size={17} color={cv(cfg.iconColor)} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="min-w-0 flex-1">
|
<Box className="min-w-0 flex-1">
|
||||||
<Text fz={13} fw={600} c="edr-text" truncate>Booking {booking.reference} {verb}</Text>
|
<Text fz={13} fw={600} c="edr-text" truncate>
|
||||||
|
Booking {booking.reference} {verb}
|
||||||
|
</Text>
|
||||||
<Text fz={11} c="edr-muted" truncate>
|
<Text fz={11} c="edr-muted" truncate>
|
||||||
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "}
|
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "}
|
||||||
{booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
|
{booking.destinationYard?.label ??
|
||||||
|
booking.destinationYard?.code ??
|
||||||
|
"—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={11} c="edr-muted" className="shrink-0">{format(new Date(booking.createdAt), "MMM d")}</Text>
|
<Text fz={11} c="edr-muted" className="shrink-0">
|
||||||
|
{format(new Date(booking.createdAt), "MMM d")}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ message }: { message: string }) {
|
function EmptyState({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<Box py="xl" className="rounded-xl border border-dashed border-edr-border text-center">
|
<Box
|
||||||
<Text size="sm" c="edr-muted">{message}</Text>
|
py="xl"
|
||||||
|
className="rounded-xl border border-dashed border-edr-border text-center"
|
||||||
|
>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
Building2,
|
Building2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Ship,
|
|
||||||
Truck,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -27,37 +33,37 @@ const USER_TYPE_CARDS: {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
id: "importer",
|
id: "importer",
|
||||||
label: "Importer",
|
label: "Importer",
|
||||||
description: "Import goods into Ethiopia via the railway corridor.",
|
description: "Import goods into Ethiopia via the railway corridor.",
|
||||||
icon: <ArrowDownToLine size={22} />,
|
icon: <ArrowDownToLine size={22} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "exporter",
|
id: "exporter",
|
||||||
label: "Exporter",
|
label: "Exporter",
|
||||||
description: "Export goods from Ethiopia via rail.",
|
description: "Export goods from Ethiopia via rail.",
|
||||||
icon: <ArrowUpFromLine size={22} />,
|
icon: <ArrowUpFromLine size={22} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "freight-forwarder-et",
|
id: "freight-forwarder-et",
|
||||||
label: "Freight Forwarder (Ethiopia)",
|
label: "Freight Forwarder (Ethiopia)",
|
||||||
description: "Ethiopian freight forwarding company handling client cargo.",
|
description: "Ethiopian freight forwarding company handling client cargo.",
|
||||||
icon: <Building2 size={22} />,
|
icon: <Building2 size={22} />,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: "freight-forwarder-dj",
|
// id: "freight-forwarder-dj",
|
||||||
label: "FF Agent (Djibouti)",
|
// label: "FF Agent (Djibouti)",
|
||||||
description: "Djibouti-based agent coordinating cross-border logistics.",
|
// description: "Djibouti-based agent coordinating cross-border logistics.",
|
||||||
icon: <Ship size={22} />,
|
// icon: <Ship size={22} />,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: "transporter",
|
// id: "transporter",
|
||||||
label: "Transporter",
|
// label: "Transporter",
|
||||||
description: "Trucking company providing first/last-mile services.",
|
// description: "Trucking company providing first/last-mile services.",
|
||||||
icon: <Truck size={22} />,
|
// icon: <Truck size={22} />,
|
||||||
},
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
const USER_TYPE_LEFT_MAP: Record<
|
const USER_TYPE_LEFT_MAP: Record<
|
||||||
OnboardingUserType,
|
OnboardingUserType,
|
||||||
@@ -105,7 +111,12 @@ const PREFLIGHT_LEFT = {
|
|||||||
"Freight Forwarders (Ethiopia & Djibouti)",
|
"Freight Forwarders (Ethiopia & Djibouti)",
|
||||||
"Transporters & Fleet Operators",
|
"Transporters & Fleet Operators",
|
||||||
],
|
],
|
||||||
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
|
stats: {
|
||||||
|
label: "Active Customers",
|
||||||
|
value: "500+",
|
||||||
|
footer: "And growing",
|
||||||
|
progress: "w-[95%]",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
|
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
|
||||||
@@ -120,7 +131,9 @@ export default function OnboardingPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
||||||
importer: "customer",
|
importer: "customer",
|
||||||
@@ -131,7 +144,8 @@ export default function OnboardingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createCompanyMutation = useMutation({
|
const createCompanyMutation = useMutation({
|
||||||
mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload),
|
mutationFn: (payload: CreateCompanyPayload) =>
|
||||||
|
api.companies.create.call(payload),
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
const hasFiles = Object.values(documentFiles).some(
|
const hasFiles = Object.values(documentFiles).some(
|
||||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||||
@@ -139,14 +153,19 @@ export default function OnboardingPage() {
|
|||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
await companiesService.uploadDocuments(data.company.id, documentFiles);
|
await companiesService.uploadDocuments(data.company.id, documentFiles);
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() });
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const handleSubmit = (payload: CreateCompanyPayload) => {
|
const handleSubmit = (payload: CreateCompanyPayload) => {
|
||||||
const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] };
|
const enriched: CreateCompanyPayload = {
|
||||||
|
...payload,
|
||||||
|
companyType: COMPANY_TYPE_MAP[userType!],
|
||||||
|
};
|
||||||
createCompanyMutation.mutate(enriched);
|
createCompanyMutation.mutate(enriched);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,11 +228,28 @@ export default function OnboardingPage() {
|
|||||||
...leftConfig,
|
...leftConfig,
|
||||||
features:
|
features:
|
||||||
userType === "transporter"
|
userType === "transporter"
|
||||||
? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"]
|
? [
|
||||||
|
"Vehicle & fleet registration",
|
||||||
|
"TIN & FAN verification",
|
||||||
|
"First-mile / Last-mile eligibility",
|
||||||
|
]
|
||||||
: userType === "freight-forwarder-dj"
|
: userType === "freight-forwarder-dj"
|
||||||
? ["Company details", "Representative information", "Cross-border operations"]
|
? [
|
||||||
: ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"],
|
"Company details",
|
||||||
stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" },
|
"Representative information",
|
||||||
|
"Cross-border operations",
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"Company registration details",
|
||||||
|
"Contact and management personnel",
|
||||||
|
"Power of Attorney (optional)",
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
label: "Active Customers",
|
||||||
|
value: "500+",
|
||||||
|
footer: "And growing",
|
||||||
|
progress: "w-[95%]",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -71,10 +71,12 @@ export function DraftBookingView({
|
|||||||
).length;
|
).length;
|
||||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||||
|
|
||||||
const { data: generatedPricing } = useQuery({
|
const { data: generatedPricing } = useQuery(
|
||||||
...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }),
|
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
|
||||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
|
||||||
});
|
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
|
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DocRow, IconSquare } from "./components/Documents";
|
|||||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||||
import { CancelledBanner } from "./components/Notices";
|
import { CancelledBanner } from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
import { PaymentCard } from "./components/pricing";
|
import { PaymentCard } from "./components/pricing";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
@@ -32,14 +33,17 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
|
|
||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
const canPay =
|
const canPay =
|
||||||
status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID";
|
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||||
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
|
const isExpired = status === "EXPIRED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
booking={booking}
|
booking={booking}
|
||||||
actions={
|
actions={
|
||||||
canPay && (
|
canPay &&
|
||||||
|
!showCountdown && (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
@@ -70,6 +74,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
reason={booking.latestChangeRequestNote}
|
reason={booking.latestChangeRequestNote}
|
||||||
onRebook={() => navigate("/bookings/new")}
|
onRebook={() => navigate("/bookings/new")}
|
||||||
/>
|
/>
|
||||||
|
) : isExpired ? (
|
||||||
|
<CancelledBanner
|
||||||
|
pillLabel="Expired"
|
||||||
|
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||||
|
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||||
|
onRebook={() => navigate("/bookings/new")}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusHero booking={booking} />
|
<StatusHero booking={booking} />
|
||||||
)}
|
)}
|
||||||
@@ -114,6 +125,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<>
|
<>
|
||||||
|
{showCountdown && (
|
||||||
|
<PaymentDeadlineCard
|
||||||
|
paymentDeadline={booking.paymentDeadline!}
|
||||||
|
onPay={() => payMutation.mutate()}
|
||||||
|
paying={payMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<PaymentCard booking={booking} pricing={pricing} />
|
<PaymentCard booking={booking} pricing={pricing} />
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { CreditCard, Timer } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
interface Remaining {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemaining(deadlineMs: number): Remaining {
|
||||||
|
const diff = deadlineMs - Date.now();
|
||||||
|
if (diff <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.floor(diff / 1000);
|
||||||
|
return {
|
||||||
|
days: Math.floor(totalSeconds / 86400),
|
||||||
|
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||||
|
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||||
|
seconds: totalSeconds % 60,
|
||||||
|
expired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segment({ value, label }: { value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||||
|
<Text
|
||||||
|
fz="28px"
|
||||||
|
fw={800}
|
||||||
|
c="#10202F"
|
||||||
|
lh={1}
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{String(value).padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentDeadlineCard({
|
||||||
|
paymentDeadline,
|
||||||
|
onPay,
|
||||||
|
paying,
|
||||||
|
}: {
|
||||||
|
/** ISO timestamp marking the end of the pay window. */
|
||||||
|
paymentDeadline: string;
|
||||||
|
onPay?: () => void;
|
||||||
|
paying?: boolean;
|
||||||
|
}) {
|
||||||
|
const deadlineMs = new Date(paymentDeadline).getTime();
|
||||||
|
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(getRemaining(deadlineMs));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const next = getRemaining(deadlineMs);
|
||||||
|
setRemaining(next);
|
||||||
|
if (next.expired) clearInterval(interval);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [deadlineMs]);
|
||||||
|
|
||||||
|
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
|
||||||
|
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<CardTitle>Payment deadline</CardTitle>
|
||||||
|
<Group
|
||||||
|
component="span"
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "5px 11px",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
backgroundColor: accentBg,
|
||||||
|
color: accentFg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Timer size={13} />
|
||||||
|
{remaining.expired ? "Expired" : "Pay window open"}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{remaining.expired ? (
|
||||||
|
<Text mt={14} fz="13.5px" c="#6B7C8E">
|
||||||
|
The payment window has closed. Move this booking to another schedule or
|
||||||
|
contact support.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
|
||||||
|
<Segment value={remaining.days} label="Days" />
|
||||||
|
<Segment value={remaining.hours} label="Hrs" />
|
||||||
|
<Segment value={remaining.minutes} label="Min" />
|
||||||
|
<Segment value={remaining.seconds} label="Sec" />
|
||||||
|
</Group>
|
||||||
|
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
|
||||||
|
Complete payment before the window closes to secure your slot.
|
||||||
|
</Text>
|
||||||
|
{onPay && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={16}
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CreditCard size={17} />}
|
||||||
|
onClick={onPay}
|
||||||
|
loading={paying}
|
||||||
|
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Pay now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
|
||||||
|
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||||
|
Deadline:{" "}
|
||||||
|
{new Date(paymentDeadline).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export const PROGRESS_STAGES = [
|
|||||||
label: "In Transit",
|
label: "In Transit",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
statuses: [
|
statuses: [
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"EXPIRED",
|
||||||
"PNR_GENERATED",
|
"PNR_GENERATED",
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
"PAID",
|
"PAID",
|
||||||
@@ -99,6 +101,18 @@ export const STATUS_MAP: Record<
|
|||||||
description: "Signed by all parties. You can now proceed to payment.",
|
description: "Signed by all parties. You can now proceed to payment.",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
},
|
},
|
||||||
|
SELECTED_FOR_BATCH: {
|
||||||
|
title: "Selected for a train — payment due",
|
||||||
|
description:
|
||||||
|
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
title: "Pay window expired",
|
||||||
|
description:
|
||||||
|
"The payment window was missed. You can move this booking to another schedule or cancel it.",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
PNR_GENERATED: {
|
PNR_GENERATED: {
|
||||||
title: "Payment reference generated",
|
title: "Payment reference generated",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
STEPS,
|
STEPS,
|
||||||
@@ -22,18 +23,58 @@ import {
|
|||||||
Step2ServiceType,
|
Step2ServiceType,
|
||||||
Step4Route,
|
Step4Route,
|
||||||
Step5CargoDetails,
|
Step5CargoDetails,
|
||||||
StepDocuments,
|
|
||||||
Step8Review,
|
Step8Review,
|
||||||
|
StepDocuments,
|
||||||
|
StepScheduling,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
const auth = useAuth();
|
||||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
api.bookings.referenceData.queryOptions(),
|
api.bookings.referenceData.queryOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!auth.isPending && !auth.company) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: "28px",
|
||||||
|
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
icon={<AlertCircle size={20} />}
|
||||||
|
radius="md"
|
||||||
|
style={{ maxWidth: "500px" }}
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
<Text size="lg" fw={600} mb="md">
|
||||||
|
Complete Your Company Setup
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" mb="md">
|
||||||
|
You need to complete your company onboarding before you can create
|
||||||
|
bookings. Please follow the onboarding process to get started.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
onClick={() => navigate("/onboarding")}
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
Go to Onboarding
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (payload: CreateBookingPayload) => {
|
mutationFn: async (payload: CreateBookingPayload) => {
|
||||||
const booking = await api.bookings.create.call(payload);
|
const booking = await api.bookings.create.call(payload);
|
||||||
@@ -70,7 +111,15 @@ export default function NewBookingPage() {
|
|||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
|
|
||||||
const direction = useMemo(
|
const direction = useMemo(
|
||||||
() => getRouteDirection(originYard, destinationYard),
|
() =>{
|
||||||
|
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||||
|
const destination = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||||
|
|
||||||
|
const route = getRouteDirection(
|
||||||
|
origin,destination
|
||||||
|
)
|
||||||
|
return route
|
||||||
|
},
|
||||||
[originYard, destinationYard],
|
[originYard, destinationYard],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,30 +149,13 @@ export default function NewBookingPage() {
|
|||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
// ── Reference data lookups ──────────────────────────────────────────
|
// ── Reference data lookups ──────────────────────────────────────────
|
||||||
const yards = referenceData?.yard ?? [];
|
|
||||||
const services = referenceData?.service ?? [];
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
const cargoTree = referenceData?.cargo_type ?? [];
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findYardId = (name: string): string =>
|
|
||||||
yards.find((y) => y.name === name)?.id ?? "";
|
|
||||||
|
|
||||||
const findServiceTypeId = (): string => {
|
|
||||||
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
|
||||||
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
const findShippingLineId = (name: string): string | undefined =>
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
shippingLines.find((l) => l.name === name)?.id;
|
||||||
|
|
||||||
const findContainerCargoTypeId = (): string => {
|
|
||||||
const group = cargoTree.find(
|
|
||||||
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
|
|
||||||
);
|
|
||||||
return group?.id ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const findContainerTypeId = (name: string): string => {
|
const findContainerTypeId = (name: string): string => {
|
||||||
for (const group of containerGroups) {
|
for (const group of containerGroups) {
|
||||||
const ct = group.types.find((t) => t.name === name);
|
const ct = group.types.find((t) => t.name === name);
|
||||||
@@ -132,47 +164,42 @@ export default function NewBookingPage() {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedChild =
|
const cargoTypePath = data.cargoTypePath ?? [];
|
||||||
data.cargoType !== "container" && data.bulkCommoditytype
|
const childId = cargoTypePath[1];
|
||||||
? cargoTree
|
|
||||||
.find((g) => g.code.toLowerCase() === data.freightType)
|
const bulkChild = cargoTree
|
||||||
?.children?.find((c) => c.name === data.bulkCommoditytype)
|
.flatMap((g) => g.children ?? [])
|
||||||
: undefined;
|
.find((c) => c.id === childId);
|
||||||
|
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container"
|
data.cargoType === "bulk" ? childId : undefined;
|
||||||
? findContainerCargoTypeId()
|
|
||||||
: (selectedChild?.id ?? "");
|
|
||||||
|
|
||||||
const cargoFreeText =
|
const cargoFreeText = bulkChild?.show_free_text_box
|
||||||
data.cargoType === "container"
|
? data.cargoFreeText
|
||||||
? undefined
|
: undefined;
|
||||||
: selectedChild?.show_free_text_box
|
|
||||||
? data.bulkCommoditytype
|
const serviceType = referenceData?.service.find(
|
||||||
: undefined;
|
(s) => s.id === data.serviceTypeId,
|
||||||
|
)!;
|
||||||
|
|
||||||
// ── Build API payload ───────────────────────────────────────────────
|
// ── Build API payload ───────────────────────────────────────────────
|
||||||
const apiPayload: CreateBookingPayload = {
|
const apiPayload: CreateBookingPayload = {
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
scheduledDate: new Date().toISOString(),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: findServiceTypeId(),
|
serviceTypeId: data.serviceTypeId,
|
||||||
equipmentReturn:
|
equipmentReturn:
|
||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
: "WITHOUT_RETURN",
|
: "WITHOUT_RETURN",
|
||||||
originYardId: findYardId(data.originYard),
|
paymentCurrency: "USD",
|
||||||
destinationYardId: findYardId(data.destinationYard),
|
originYardId: data.originYard,
|
||||||
tradeDirection:
|
destinationYardId: data.destinationYard,
|
||||||
direction === "export"
|
tradeDirection: direction!,
|
||||||
? "EXPORT"
|
cargoTypeId,
|
||||||
: direction === "domestic"
|
trainScheduleId: data.trainScheduleId,
|
||||||
? "DOMESTIC"
|
|
||||||
: "IMPORT",
|
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
freightType:
|
freightType:
|
||||||
@@ -193,10 +220,10 @@ export default function NewBookingPage() {
|
|||||||
...(data.contractType === "renewal" && data.previousContractRef
|
...(data.contractType === "renewal" && data.previousContractRef
|
||||||
? { pnrCode: data.previousContractRef }
|
? { pnrCode: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
...(serviceType.includesFirstMile && data.firstMile.enabled
|
||||||
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
...(serviceType.includesLastMile && data.lastMile.enabled
|
||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
@@ -248,67 +275,61 @@ export default function NewBookingPage() {
|
|||||||
Back to Bookings
|
Back to Bookings
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="new-booking-form"
|
id="new-booking-form"
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
{/* Step indicator */}
|
<Box flex={1} p="24px">
|
||||||
<Box>
|
<Box mb="lg">
|
||||||
<Box className="mx-auto max-w-5xl" style={{ paddingInline: "16px" }}>
|
|
||||||
<StepIndicator step={step} />
|
<StepIndicator step={step} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Step content */}
|
{createMutation.isError && (
|
||||||
<Box flex={1}>
|
<Alert
|
||||||
<Box className="mx-auto max-w-5xl" style={{ padding: "32px 24px" }}>
|
color="red"
|
||||||
{createMutation.isError && (
|
icon={<AlertCircle size={16} />}
|
||||||
<Alert
|
radius="md"
|
||||||
color="red"
|
mb="lg"
|
||||||
icon={<AlertCircle size={16} />}
|
>
|
||||||
radius="md"
|
<Text size="sm" fw={600}>
|
||||||
mb="lg"
|
Failed to save draft
|
||||||
>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" mt={4} c="red.7">
|
||||||
Failed to save draft
|
{createMutation.error instanceof Error
|
||||||
</Text>
|
? createMutation.error.message
|
||||||
<Text size="sm" mt={4} c="red.7">
|
: "An unexpected error occurred. Please try again."}
|
||||||
{createMutation.error instanceof Error
|
</Text>
|
||||||
? createMutation.error.message
|
</Alert>
|
||||||
: "An unexpected error occurred. Please try again."}
|
)}
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && <Step1ContractType form={form} />}
|
{step === 1 && <Step1ContractType form={form} referenceData={referenceData} />}
|
||||||
{step === 2 && <Step2ServiceType form={form} />}
|
{step === 2 && (
|
||||||
{step === 3 && (
|
<Step2ServiceType referenceData={referenceData} form={form} />
|
||||||
<Step4Route
|
)}
|
||||||
form={form}
|
{step === 3 && (
|
||||||
referenceData={referenceData}
|
<Step4Route
|
||||||
isLoading={refDataLoading}
|
form={form}
|
||||||
/>
|
referenceData={referenceData}
|
||||||
)}
|
isLoading={refDataLoading}
|
||||||
{step === 4 && (
|
/>
|
||||||
<Step5CargoDetails
|
)}
|
||||||
form={form}
|
{step === 4 && (
|
||||||
direction={direction}
|
<Step5CargoDetails
|
||||||
referenceData={referenceData}
|
form={form}
|
||||||
isLoading={refDataLoading}
|
direction={direction!}
|
||||||
/>
|
referenceData={referenceData}
|
||||||
)}
|
isLoading={refDataLoading}
|
||||||
{step === 5 && <StepDocuments form={form} />}
|
/>
|
||||||
{step === 6 && (
|
)}
|
||||||
<Step8Review
|
{step === 5 && (
|
||||||
form={form}
|
<StepScheduling form={form} referenceData={referenceData} />
|
||||||
setStep={setStep}
|
)}
|
||||||
direction={direction}
|
{step === 6 && <StepDocuments form={form} />}
|
||||||
/>
|
{step === 7 && (
|
||||||
)}
|
<Step8Review form={form} setStep={setStep} direction={direction!} />
|
||||||
</Box>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Navigation footer */}
|
{/* Navigation footer */}
|
||||||
@@ -364,6 +385,7 @@ export default function NewBookingPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
|
{/* <DevTool control={form.control} /> */}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ import type { Freight } from "@edr/types";
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
export const ETHIOPIA_STATIONS = new Set<string>([
|
|
||||||
"Addis Ababa",
|
|
||||||
"Adama",
|
|
||||||
"Mojo",
|
|
||||||
"Awash",
|
|
||||||
"Mieso",
|
|
||||||
"Dire Dawa",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const MOCK_VALID_CONTRACTS = [
|
export const MOCK_VALID_CONTRACTS = [
|
||||||
"EDR-2024-10001",
|
"EDR-2024-10001",
|
||||||
"EDR-2024-10002",
|
"EDR-2024-10002",
|
||||||
@@ -23,8 +14,9 @@ export const STEPS = [
|
|||||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||||
{ id: 3, label: "Route", short: "Route" },
|
{ id: 3, label: "Route", short: "Route" },
|
||||||
{ id: 4, label: "Cargo Details", short: "Cargo" },
|
{ id: 4, label: "Cargo Details", short: "Cargo" },
|
||||||
{ id: 5, label: "Documents", short: "Documents" },
|
{ id: 5, label: "Shipment Date", short: "Schedule" },
|
||||||
{ id: 6, label: "Review & Submit", short: "Submit" },
|
{ id: 6, label: "Documents", short: "Documents" },
|
||||||
|
{ id: 7, label: "Review & Submit", short: "Submit" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +74,7 @@ export const bookingFormSchema = z
|
|||||||
.object({
|
.object({
|
||||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string(),
|
previousContractRef: z.string(),
|
||||||
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
|
serviceTypeId: z.string("Select a service type."),
|
||||||
firstMile: z
|
firstMile: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
@@ -108,10 +100,12 @@ export const bookingFormSchema = z
|
|||||||
originYard: z.string().min(1, "Select an origin yard."),
|
originYard: z.string().min(1, "Select an origin yard."),
|
||||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||||
shippingLine: z.string(),
|
shippingLine: z.string(),
|
||||||
|
scheduledDate: z.string().min(1, "Select a shipment date."),
|
||||||
|
trainScheduleId: z.string().min(1, "Select a shipment date."),
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
freightType: z.string(), // parent group
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
bulkCommoditytype: z.string(),
|
cargoFreeText: z.string(),
|
||||||
isHazardous: z.boolean(),
|
isHazardous: z.boolean(),
|
||||||
isRefrigerated: z.boolean(),
|
isRefrigerated: z.boolean(),
|
||||||
containers: z.array(
|
containers: z.array(
|
||||||
@@ -163,19 +157,6 @@ export const bookingFormSchema = z
|
|||||||
path: ["destinationYard"],
|
path: ["destinationYard"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine((data) => !(data.cargoType === "bulk" && !data.freightType), {
|
|
||||||
message: "Select a freight type.",
|
|
||||||
path: ["freightType"],
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) =>
|
|
||||||
!(
|
|
||||||
data.cargoType === "bulk" &&
|
|
||||||
data.freightType &&
|
|
||||||
!data.bulkCommoditytype
|
|
||||||
),
|
|
||||||
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
|
|
||||||
)
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.cargoType !== "bulk") return true;
|
if (data.cargoType !== "bulk") return true;
|
||||||
@@ -195,6 +176,21 @@ export const bookingFormSchema = z
|
|||||||
path: ["termsAccepted"],
|
path: ["termsAccepted"],
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.cargoType === "bulk") {
|
||||||
|
if (!data.cargoTypePath[0]) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["cargoTypePath"],
|
||||||
|
message: "Select a freight type.",
|
||||||
|
});
|
||||||
|
} else if (!data.cargoTypePath[1]) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["cargoTypePath"],
|
||||||
|
message: "Select a commodity.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.cargoType === "container") {
|
if (data.cargoType === "container") {
|
||||||
data.containers.forEach((c, i) => {
|
data.containers.forEach((c, i) => {
|
||||||
if (!c.qty || +c.qty < 1) {
|
if (!c.qty || +c.qty < 1) {
|
||||||
@@ -235,8 +231,11 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
originYard: "",
|
originYard: "",
|
||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
shippingLine: "",
|
shippingLine: "",
|
||||||
|
scheduledDate: "",
|
||||||
|
trainScheduleId: "",
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
bulkCommoditytype: "",
|
cargoTypePath: [],
|
||||||
|
cargoFreeText: "",
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
@@ -249,7 +248,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||||
1: ["contractType", "previousContractRef"],
|
1: ["contractType", "previousContractRef"],
|
||||||
2: [
|
2: [
|
||||||
"serviceType",
|
"serviceTypeId",
|
||||||
"firstMile",
|
"firstMile",
|
||||||
"lastMile",
|
"lastMile",
|
||||||
"equipmentReturn",
|
"equipmentReturn",
|
||||||
@@ -265,17 +264,15 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
4: [
|
4: [
|
||||||
"cargoType",
|
"cargoType",
|
||||||
"cargoWeight",
|
"cargoWeight",
|
||||||
"freightType",
|
"cargoTypePath",
|
||||||
"bulkCommoditytype",
|
|
||||||
"containers",
|
"containers",
|
||||||
"consolidationEnabled",
|
"consolidationEnabled",
|
||||||
],
|
],
|
||||||
5: ["documents"],
|
5: ["scheduledDate", "trainScheduleId"],
|
||||||
6: ["notes", "termsAccepted"],
|
6: ["documents"],
|
||||||
|
7: ["notes", "termsAccepted"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteDirection = "import" | "export" | "domestic" | null;
|
|
||||||
|
|
||||||
export interface ContainerConfig {
|
export interface ContainerConfig {
|
||||||
type: "20ft" | "40ft";
|
type: "20ft" | "40ft";
|
||||||
containerType: string;
|
containerType: string;
|
||||||
@@ -287,64 +284,32 @@ export interface WagonConfig {
|
|||||||
type: "20ft" | "40ft";
|
type: "20ft" | "40ft";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WagonCalcResult {
|
|
||||||
totalWagons: number;
|
|
||||||
hasOddUnit: boolean;
|
|
||||||
sharedWagons: number;
|
|
||||||
wagonLayout: WagonConfig[];
|
|
||||||
ft40Wagons: number;
|
|
||||||
ft20Wagons: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function genContractId(): string {
|
|
||||||
const yr = new Date().getFullYear();
|
|
||||||
const n = Math.floor(10000 + Math.random() * 90000);
|
|
||||||
return `EDR-DRAFT-${yr}-${n}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRouteDirection(
|
export function getRouteDirection(
|
||||||
origin: string,
|
origin: Freight.BookingReferenceYard | null | undefined,
|
||||||
dest: string,
|
dest: Freight.BookingReferenceYard | null | undefined,
|
||||||
): RouteDirection {
|
): Freight.ScheduleTradeDirection | null {
|
||||||
if (!origin || !dest) return null;
|
if (!origin || !dest) return null;
|
||||||
const oLocation = getStationLocation(origin);
|
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
|
||||||
const dLocation = getStationLocation(dest);
|
return "DOMESTIC";
|
||||||
if (oLocation === "inside" && dLocation === "outside") return "export";
|
}
|
||||||
if (oLocation === "outside" && dLocation === "inside") return "import";
|
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||||
if (oLocation === "inside" && dLocation === "inside") return "domestic";
|
return "IMPORT";
|
||||||
|
}
|
||||||
|
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||||
|
return "EXPORT";
|
||||||
|
}
|
||||||
|
|
||||||
const oEth = ETHIOPIA_STATIONS.has(origin);
|
|
||||||
const dEth = ETHIOPIA_STATIONS.has(dest);
|
|
||||||
if (oEth && !dEth) return "export";
|
|
||||||
if (!oEth && dEth) return "import";
|
|
||||||
if (oEth && dEth) return "domestic";
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStationLocation(value: string): "inside" | "outside" | null {
|
export function calcWagons(containers: ContainerConfig[]) {
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (normalized.startsWith("inside")) return "inside";
|
|
||||||
if (normalized.startsWith("outside")) return "outside";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
|
||||||
const Ft40Wagons = containers
|
|
||||||
.filter((c) => c.type === "40ft")
|
|
||||||
.reduce((sum, c) => sum + Number(c.qty), 0);
|
|
||||||
const Ft20Wagons = containers
|
const Ft20Wagons = containers
|
||||||
.filter((c) => c.type === "20ft")
|
.filter((c) => c.type === "20ft")
|
||||||
.reduce((sum, c) => sum + Number(c.qty), 0);
|
.reduce((sum, c) => sum + Number(c.qty), 0);
|
||||||
const wagonLayout: WagonConfig[] = [];
|
|
||||||
let hasOddUnit = Ft20Wagons % 2 === 1;
|
let hasOddUnit = Ft20Wagons % 2 === 1;
|
||||||
let sharedWagons = Math.floor(Ft20Wagons / 2);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalWagons: sharedWagons + Ft40Wagons,
|
|
||||||
hasOddUnit,
|
hasOddUnit,
|
||||||
sharedWagons,
|
|
||||||
ft40Wagons: Ft40Wagons,
|
|
||||||
ft20Wagons: Ft20Wagons,
|
ft20Wagons: Ft20Wagons,
|
||||||
wagonLayout,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
|
||||||
|
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import 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 { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react";
|
|
||||||
import { Alert, Select, Text, Title } from "@mantine/core";
|
|
||||||
import type { BookingFormInputValues } from "./schema";
|
import type { BookingFormInputValues } from "./schema";
|
||||||
|
|
||||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||||
@@ -70,7 +71,7 @@ export function AlertBox({
|
|||||||
|
|
||||||
export function StepLabel({ children }: { children: ReactNode }) {
|
export function StepLabel({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -120,8 +121,96 @@ export function SelectField({
|
|||||||
onChange={(v) => field.onChange(v ?? "")}
|
onChange={(v) => field.onChange(v ?? "")}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
radius="md"
|
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AsyncComboboxOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncComboboxField({
|
||||||
|
field,
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
isLoading,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
field: ControllerRenderProps<BookingFormInputValues>;
|
||||||
|
error?: RhfFieldError;
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
options: AsyncComboboxOption[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const combobox = useCombobox();
|
||||||
|
|
||||||
|
const selectedLabel = useMemo(() => {
|
||||||
|
return options.find((opt) => opt.value === field.value)?.label || "";
|
||||||
|
}, [field.value, options]);
|
||||||
|
|
||||||
|
const handleSelectOption = (val: string) => {
|
||||||
|
onSelect(val);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper label={label} error={error?.message}>
|
||||||
|
<Combobox store={combobox} disabled={disabled}>
|
||||||
|
<Combobox.Target>
|
||||||
|
<InputBase
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
value={searchQuery || selectedLabel}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSearchChange(e.currentTarget.value);
|
||||||
|
combobox.openDropdown();
|
||||||
|
}}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => {
|
||||||
|
field.onBlur();
|
||||||
|
combobox.closeDropdown();
|
||||||
|
if (!selectedLabel) {
|
||||||
|
onSearchChange("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
isLoading ? <Loader size={14} /> : <Combobox.Chevron />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{isLoading ? (
|
||||||
|
<Combobox.Empty>Loading contracts...</Combobox.Empty>
|
||||||
|
) : options.length === 0 ? (
|
||||||
|
<Combobox.Empty>No contracts found</Combobox.Empty>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onClick={() => handleSelectOption(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
|
BookingFormInputValues,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
function countAttached(documents: BookingDocuments): number {
|
function countAttached(documents: BookingDocuments): number {
|
||||||
return BOOKING_DOCS_SETTING.fields.filter((f) => {
|
return BOOKING_DOCS_SETTING.fields.filter((f) => {
|
||||||
@@ -57,7 +62,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
|||||||
color: attached === total ? "#0A6F4D" : "#2E5B96",
|
color: attached === total ? "#0A6F4D" : "#2E5B96",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{attached === total ? <CheckCircle2 size={16} /> : `${attached}/${total}`}
|
{attached === total ? (
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
) : (
|
||||||
|
`${attached}/${total}`
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{attached === 0
|
{attached === 0
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Check,
|
||||||
|
Train,
|
||||||
|
Route,
|
||||||
|
Package,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isToday,
|
||||||
|
isSameMonth,
|
||||||
|
addMonths,
|
||||||
|
} from "date-fns";
|
||||||
|
|
||||||
|
interface StepSchedulingProps {
|
||||||
|
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayData {
|
||||||
|
day: number;
|
||||||
|
dateString: string;
|
||||||
|
isToday: boolean;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isSelectedDate: boolean;
|
||||||
|
schedules: Freight.BookableScheduleItem[];
|
||||||
|
hasSchedule: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
const selectedDate = form.watch("scheduledDate");
|
||||||
|
const selectedScheduleId = form.watch("trainScheduleId");
|
||||||
|
const originYardId = form.watch("originYard");
|
||||||
|
const destinationYardId = form.watch("destinationYard");
|
||||||
|
const cargoType = form.watch("cargoType");
|
||||||
|
const containers = form.watch("containers");
|
||||||
|
const cargoTypePath = form.watch("cargoTypePath");
|
||||||
|
const cargoWeight = form.watch("cargoWeight");
|
||||||
|
|
||||||
|
const originName = useMemo(
|
||||||
|
() => referenceData?.yard.find((y) => y.id === originYardId)?.name ?? "—",
|
||||||
|
[referenceData, originYardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationName = useMemo(
|
||||||
|
() =>
|
||||||
|
referenceData?.yard.find((y) => y.id === destinationYardId)?.name ?? "—",
|
||||||
|
[referenceData, destinationYardId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: bookableSchedules } = useQuery(
|
||||||
|
api.bookings.getBookableSchedules.queryOptions({
|
||||||
|
input: { originYardId, destinationYardId },
|
||||||
|
enabled: !!originYardId && !!destinationYardId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group all schedules per date — multiple departures per day are allowed.
|
||||||
|
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
|
||||||
|
// match the format used by the calendar day keys.
|
||||||
|
const schedulesByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, Freight.BookableScheduleItem[]>();
|
||||||
|
if (bookableSchedules) {
|
||||||
|
for (const s of bookableSchedules) {
|
||||||
|
const dateKey = s.scheduleDate.slice(0, 10);
|
||||||
|
const existing = map.get(dateKey) ?? [];
|
||||||
|
map.set(dateKey, [...existing, s]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [bookableSchedules]);
|
||||||
|
|
||||||
|
const selectedSchedule = useMemo(
|
||||||
|
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
|
||||||
|
[bookableSchedules, selectedScheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
schedulesByDate.forEach((schedules) => {
|
||||||
|
if (schedules.some((s) => s.remainingWagons > 0)) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}, [schedulesByDate]);
|
||||||
|
|
||||||
|
const days = useMemo((): DayData[] => {
|
||||||
|
const monthStart = startOfMonth(currentDate);
|
||||||
|
const monthEnd = endOfMonth(currentDate);
|
||||||
|
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||||
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
||||||
|
const dateString = format(date, "yyyy-MM-dd");
|
||||||
|
const schedules = schedulesByDate.get(dateString) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: date.getDate(),
|
||||||
|
dateString,
|
||||||
|
isToday: isToday(date),
|
||||||
|
isCurrentMonth: isSameMonth(date, currentDate),
|
||||||
|
isSelectedDate: selectedDate === dateString,
|
||||||
|
schedules,
|
||||||
|
hasSchedule: schedules.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [currentDate, schedulesByDate, selectedDate]);
|
||||||
|
|
||||||
|
const cargoSummary = useMemo(() => {
|
||||||
|
if (!cargoType) return "Not selected";
|
||||||
|
if (cargoType === "container") {
|
||||||
|
const parts = (containers ?? [])
|
||||||
|
.filter((c) => Number(c.qty) > 0)
|
||||||
|
.map((c) => `${c.qty} × ${c.type}`);
|
||||||
|
return parts.length > 0 ? `Container · ${parts.join(", ")}` : "Container";
|
||||||
|
}
|
||||||
|
const childId = cargoTypePath?.[1];
|
||||||
|
const commodity = referenceData?.cargo_type
|
||||||
|
.flatMap((g) => g.children ?? [])
|
||||||
|
.find((c) => c.id === childId);
|
||||||
|
const weight = cargoWeight ? ` · ${cargoWeight} t` : "";
|
||||||
|
return commodity ? `${commodity.name}${weight}` : "Bulk freight";
|
||||||
|
}, [cargoType, containers, cargoTypePath, cargoWeight, referenceData]);
|
||||||
|
|
||||||
|
const weeksCount = Math.ceil(days.length / 7);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={24} align="flex-start" wrap="nowrap">
|
||||||
|
{/* ── Calendar Card ───────────────────────────────────── */}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card p={0} style={{ overflow: "hidden" }}>
|
||||||
|
{/* Card header */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
px={24}
|
||||||
|
py={20}
|
||||||
|
style={{
|
||||||
|
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={3}>
|
||||||
|
<Text fw={800} fz={18} c="edr-text.0">
|
||||||
|
Select a shipment date
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
Confirmed train departures · {originName} → {destinationName}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap={10}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
w={34}
|
||||||
|
h={34}
|
||||||
|
p={0}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setCurrentDate((d) => addMonths(d, -1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</Button>
|
||||||
|
<Text fw={700} fz={14} c="edr-text.0" style={{ minWidth: 90, textAlign: "center" }}>
|
||||||
|
{format(currentDate, "MMMM yyyy")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
w={34}
|
||||||
|
h={34}
|
||||||
|
p={0}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setCurrentDate((d) => addMonths(d, 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<Stack gap={14} px={24} py={18}>
|
||||||
|
<Text fz={13} fw={600} c="edr-text.0">
|
||||||
|
{originYardId && destinationYardId
|
||||||
|
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||||
|
: "Select origin and destination to see available departures"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Weekday headers */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
|
gap: 6,
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map((d) => (
|
||||||
|
<Text
|
||||||
|
key={d}
|
||||||
|
ta="center"
|
||||||
|
fz={10.5}
|
||||||
|
fw={700}
|
||||||
|
c="edr-muted"
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Date grid */}
|
||||||
|
<Stack gap={2}>
|
||||||
|
{Array.from({ length: weeksCount }, (_, wi) => (
|
||||||
|
<Box
|
||||||
|
key={wi}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
||||||
|
<DayCell
|
||||||
|
key={di}
|
||||||
|
day={d}
|
||||||
|
selectedScheduleId={selectedScheduleId}
|
||||||
|
onSelectSchedule={(scheduleId, dateString) => {
|
||||||
|
form.setValue("scheduledDate", dateString, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
form.setValue("trainScheduleId", scheduleId, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Side Panel ──────────────────────────────────────── */}
|
||||||
|
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
|
||||||
|
{/* Booking summary card */}
|
||||||
|
<Card p={0} style={{ overflow: "hidden" }}>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
px={20}
|
||||||
|
py={18}
|
||||||
|
style={{
|
||||||
|
borderBottom: `1px solid ${theme.colors["edr-border"][0]}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fw={800} fz={15} c="edr-text.0">
|
||||||
|
Booking summary
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={14} px={20} py={18}>
|
||||||
|
<SummaryRow
|
||||||
|
icon={<Route size={17} color={theme.colors["edr-muted"][0]} />}
|
||||||
|
label="ROUTE"
|
||||||
|
value={`${originName} → ${destinationName}`}
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
icon={<Package size={17} color={theme.colors["edr-muted"][0]} />}
|
||||||
|
label="CARGO"
|
||||||
|
value={cargoSummary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedSchedule && selectedDate && (
|
||||||
|
<Box
|
||||||
|
p={14}
|
||||||
|
style={{
|
||||||
|
borderRadius: theme.radius.lg,
|
||||||
|
backgroundColor: theme.colors["edr-soft"][0],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
<Group gap={7}>
|
||||||
|
<Train size={16} color={theme.colors["edr-green"][7]} />
|
||||||
|
<Text
|
||||||
|
fz={10}
|
||||||
|
fw={700}
|
||||||
|
c="edr-green.7"
|
||||||
|
style={{ letterSpacing: "0.08em" }}
|
||||||
|
>
|
||||||
|
SELECTED DEPARTURE
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={800} fz={16} c="edr-text.0">
|
||||||
|
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||||
|
</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz={12.5} c="edr-muted">
|
||||||
|
Train
|
||||||
|
</Text>
|
||||||
|
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||||
|
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz={12.5} c="edr-muted">
|
||||||
|
Wagons available
|
||||||
|
</Text>
|
||||||
|
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||||
|
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Help card */}
|
||||||
|
<Box
|
||||||
|
p={18}
|
||||||
|
style={{
|
||||||
|
borderRadius: theme.radius.xl,
|
||||||
|
backgroundColor: theme.colors["edr-ink"][0],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Group gap={8} align="center">
|
||||||
|
<CalendarIcon size={18} color={theme.colors["edr-green"][5]} />
|
||||||
|
<Text fw={700} fz={14} c="white">
|
||||||
|
Need a different date?
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz={12.5} style={{ color: "#A7B6C2", lineHeight: 1.5 }}>
|
||||||
|
Our freight desk can arrange charter departures for full-train loads.
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} fw={700} c="edr-green.5" style={{ cursor: "pointer" }}>
|
||||||
|
Contact freight desk →
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayCellProps {
|
||||||
|
day: DayData;
|
||||||
|
selectedScheduleId: string;
|
||||||
|
onSelectSchedule: (scheduleId: string, dateString: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
|
if (!d.isCurrentMonth) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 92,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
opacity: 0.4,
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: "4px 6px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz={14} fw={600} c="edr-muted">
|
||||||
|
{d.day}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellBg = d.isSelectedDate
|
||||||
|
? theme.colors["edr-soft"][0]
|
||||||
|
: d.hasSchedule
|
||||||
|
? "#FFFFFF"
|
||||||
|
: "transparent";
|
||||||
|
|
||||||
|
const cellBorder = d.isSelectedDate
|
||||||
|
? `1.5px solid ${theme.colors["edr-green"][5]}`
|
||||||
|
: d.hasSchedule
|
||||||
|
? "1px solid #E7E8E5"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 92,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
backgroundColor: cellBg,
|
||||||
|
border: cellBorder,
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: "4px 6px 6px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Day number + check icon */}
|
||||||
|
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<Text
|
||||||
|
fz={14}
|
||||||
|
fw={d.hasSchedule ? 800 : 600}
|
||||||
|
c={
|
||||||
|
d.isToday && !d.isSelectedDate
|
||||||
|
? "edr-green.6"
|
||||||
|
: d.hasSchedule
|
||||||
|
? "edr-text.0"
|
||||||
|
: "edr-muted"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.day}
|
||||||
|
</Text>
|
||||||
|
{d.isSelectedDate && (
|
||||||
|
<Check
|
||||||
|
size={15}
|
||||||
|
color={theme.colors["edr-green"][5]}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Departure chips */}
|
||||||
|
{d.hasSchedule && (
|
||||||
|
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
|
||||||
|
{d.schedules.slice(0, 2).map((s) => {
|
||||||
|
const isChipSelected = s.id === selectedScheduleId;
|
||||||
|
const isFull = s.remainingWagons <= 0;
|
||||||
|
const canSelect = !isFull;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={s.id}
|
||||||
|
onClick={() =>
|
||||||
|
canSelect && onSelectSchedule(s.id, d.dateString)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: "4px 6px",
|
||||||
|
cursor: canSelect ? "pointer" : "default",
|
||||||
|
backgroundColor: isChipSelected
|
||||||
|
? theme.colors["edr-green"][5]
|
||||||
|
: isFull
|
||||||
|
? theme.colors["edr-red-soft"][0]
|
||||||
|
: theme.colors["edr-soft"][0],
|
||||||
|
border: `1px solid ${
|
||||||
|
isChipSelected
|
||||||
|
? theme.colors["edr-green"][5]
|
||||||
|
: isFull
|
||||||
|
? "#EFCFCA"
|
||||||
|
: "#BFE3D4"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: isChipSelected
|
||||||
|
? "white"
|
||||||
|
: isFull
|
||||||
|
? theme.colors["edr-red"][0]
|
||||||
|
: theme.colors["edr-green"][5],
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fz={11}
|
||||||
|
fw={700}
|
||||||
|
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
|
||||||
|
truncate
|
||||||
|
c={
|
||||||
|
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFull
|
||||||
|
? "Full"
|
||||||
|
: s.trainNumber
|
||||||
|
? s.trainNumber
|
||||||
|
: `${s.remainingWagons} wgn`}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
color={
|
||||||
|
isChipSelected
|
||||||
|
? "white"
|
||||||
|
: isFull
|
||||||
|
? theme.colors["edr-muted"][0]
|
||||||
|
: theme.colors["edr-green"][7]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
return (
|
||||||
|
<Group gap={12} align="flex-start" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={34}
|
||||||
|
h={34}
|
||||||
|
style={{
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
backgroundColor: theme.colors.gray[0],
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text fz={10} fw={700} style={{ color: "#9AA8B5", letterSpacing: "0.08em" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} fw={600} c="edr-text.0">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,96 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { api } from "@/services/api";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FileText, RefreshCw } from "lucide-react";
|
import { FileText, RefreshCw } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
MOCK_VALID_CONTRACTS,
|
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import {
|
import {
|
||||||
AlertBox,
|
AlertBox,
|
||||||
|
AsyncComboboxField,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionFieldError,
|
OptionFieldError,
|
||||||
SelectField,
|
|
||||||
StepHeader,
|
StepHeader,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
||||||
|
|
||||||
export function Step1ContractType({ form }: { form: BookingForm }) {
|
interface PreviousContractOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1ContractType({
|
||||||
|
form,
|
||||||
|
referenceData,
|
||||||
|
}: {
|
||||||
|
form: BookingForm;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
}) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
const previousContractRef = form.watch("previousContractRef");
|
const previousContractRef = form.watch("previousContractRef");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const { data: bookings, isLoading, error } = useQuery(
|
||||||
|
api.bookings.list.queryOptions({
|
||||||
|
input: {
|
||||||
|
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const contractOptions = useMemo<PreviousContractOption[]>(() => {
|
||||||
|
console.log("Bookings data:", bookings);
|
||||||
|
if(!bookings) return []
|
||||||
|
|
||||||
|
return bookings?.items
|
||||||
|
.map((booking) => {
|
||||||
|
const origin = booking.originYard?.label || "Unknown";
|
||||||
|
const destination = booking.destinationYard?.label || "Unknown";
|
||||||
|
return {
|
||||||
|
value: booking.id,
|
||||||
|
label: `${booking.reference} - Route: ${origin} to ${destination}`,
|
||||||
|
booking,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((opt) =>
|
||||||
|
opt.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [bookings, searchQuery]);
|
||||||
|
|
||||||
|
const handleSelectContract = async (contractId: string) => {
|
||||||
|
const selected = contractOptions.find((opt) => opt.value === contractId);
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
form.setValue("previousContractRef", contractId);
|
||||||
|
|
||||||
|
// Auto-fill from previous contract
|
||||||
|
const booking = selected.booking;
|
||||||
|
if (booking) {
|
||||||
|
form.setValue("serviceTypeId", booking.serviceTypeId);
|
||||||
|
form.setValue("cargoType", booking.freightType === "CONTAINER" ? "container" : "bulk");
|
||||||
|
form.setValue("equipmentReturn", booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return");
|
||||||
|
if (booking.isHazardous) form.setValue("isHazardous", booking.isHazardous);
|
||||||
|
|
||||||
|
// Look up shipping line name from reference data
|
||||||
|
if (booking.shippingLineId && referenceData?.shipping_line) {
|
||||||
|
const shippingLine = referenceData.shipping_line.find(
|
||||||
|
(sl) => sl.id === booking.shippingLineId,
|
||||||
|
);
|
||||||
|
if (shippingLine) {
|
||||||
|
form.setValue("shippingLine", shippingLine.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -73,23 +146,32 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
|
|||||||
|
|
||||||
{contractType === "renewal" && (
|
{contractType === "renewal" && (
|
||||||
<div className="space-y-3 pt-1">
|
<div className="space-y-3 pt-1">
|
||||||
|
{error && (
|
||||||
|
<AlertBox tone="error">
|
||||||
|
Failed to load previous contracts. Please try again later.
|
||||||
|
</AlertBox>
|
||||||
|
)}
|
||||||
<Controller
|
<Controller
|
||||||
name="previousContractRef"
|
name="previousContractRef"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<AsyncComboboxField
|
||||||
field={field}
|
field={field}
|
||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Previous Contract Reference Number"
|
label="Previous Contract Reference Number"
|
||||||
placeholder="Select a contract..."
|
placeholder="Search by reference or route..."
|
||||||
data={MOCK_VALID_CONTRACTS}
|
options={contractOptions}
|
||||||
|
isLoading={isLoading}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSelect={handleSelectContract}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{previousContractRef && (
|
{previousContractRef && (
|
||||||
<AlertBox tone="success">
|
<AlertBox tone="success">
|
||||||
<strong>Contract found.</strong> Company details, route, and wagon
|
<strong>Contract found.</strong> Route, service type, and cargo
|
||||||
preferences will be pre-filled.
|
details will be pre-filled.
|
||||||
</AlertBox>
|
</AlertBox>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
|
import { Switch, TextInput } from "@mantine/core";
|
||||||
|
import { FileText, Train, Truck } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { FileText, Package, Train, Truck } from "lucide-react";
|
|
||||||
import { Badge, Switch, TextInput } from "@mantine/core";
|
|
||||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
type BookingForm = UseFormReturn<
|
||||||
const serviceType = form.watch("serviceType");
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function Step2ServiceType({
|
||||||
|
form,
|
||||||
|
referenceData,
|
||||||
|
}: {
|
||||||
|
form: BookingForm;
|
||||||
|
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
}) {
|
||||||
|
const serviceTypeId = form.watch("serviceTypeId");
|
||||||
|
const serviceType = referenceData?.service.find(
|
||||||
|
(s) => s.id === serviceTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { includesCustoms, includesFirstMile, includesLastMile } =
|
||||||
|
serviceType ?? {};
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
|
|
||||||
const prevServiceType = useRef(serviceType);
|
const prevServiceType = useRef(serviceType);
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValue(
|
||||||
|
"firstMile",
|
||||||
|
{ enabled: false, pickUpAddress: "" },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}, [includesFirstMile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValue(
|
||||||
|
"lastMile",
|
||||||
|
{ enabled: false, deliveryAddress: "" },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}, [includesLastMile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevServiceType.current;
|
const prev = prevServiceType.current;
|
||||||
@@ -20,35 +54,12 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
|
|||||||
|
|
||||||
if (!prev || prev === serviceType) return;
|
if (!prev || prev === serviceType) return;
|
||||||
|
|
||||||
if (serviceType === "rail") {
|
if (!includesCustoms)
|
||||||
form.setValue(
|
|
||||||
"firstMile",
|
|
||||||
{ enabled: false, pickUpAddress: "" },
|
|
||||||
{ shouldDirty: true, shouldValidate: true },
|
|
||||||
);
|
|
||||||
form.setValue(
|
|
||||||
"lastMile",
|
|
||||||
{ enabled: false, deliveryAddress: "" },
|
|
||||||
{ shouldDirty: true, shouldValidate: true },
|
|
||||||
);
|
|
||||||
form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
|
|
||||||
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
||||||
} else if (serviceType === "rail_forwarding") {
|
}, [serviceTypeId, form]);
|
||||||
form.setValue(
|
|
||||||
"firstMile",
|
|
||||||
{ enabled: false, pickUpAddress: "" },
|
|
||||||
{ shouldDirty: false, shouldValidate: false },
|
|
||||||
);
|
|
||||||
form.setValue(
|
|
||||||
"lastMile",
|
|
||||||
{ enabled: false, deliveryAddress: "" },
|
|
||||||
{ shouldDirty: false, shouldValidate: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [serviceType, form]);
|
|
||||||
|
|
||||||
const showServiceSections = serviceType === "rail_forwarding";
|
|
||||||
|
|
||||||
|
const showServiceSections =
|
||||||
|
includesCustoms || includesFirstMile || includesLastMile;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -57,44 +68,29 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="serviceType"
|
name="serviceTypeId"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<OptionCard
|
{referenceData?.service
|
||||||
selected={serviceType === "rail"}
|
.filter((s) => s.canBeBookedAlone)
|
||||||
onClick={() => field.onChange("rail")}
|
.map((s) => {
|
||||||
>
|
return (
|
||||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
|
<OptionCard
|
||||||
<Train className="h-4 w-4 text-indigo-600" />
|
selected={field.value === s.id}
|
||||||
</div>
|
onClick={() => field.onChange(s.id)}
|
||||||
<p className="font-semibold">Rail Transport Only</p>
|
>
|
||||||
<p className="mt-0.5 text-xs text-gray-500">
|
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
|
||||||
Rail transport along the EDR corridor, with optional
|
<Train className="h-4 w-4 text-indigo-600" />
|
||||||
first/last mile trucking.
|
</div>
|
||||||
</p>
|
<p className="font-semibold">{s.serviceName}</p>
|
||||||
<Badge color="indigo" variant="light" mt="xs" size="sm">
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
Option A
|
{s.description}
|
||||||
</Badge>
|
</p>
|
||||||
</OptionCard>
|
</OptionCard>
|
||||||
|
);
|
||||||
<OptionCard
|
})}
|
||||||
selected={serviceType === "rail_forwarding"}
|
|
||||||
onClick={() => field.onChange("rail_forwarding")}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
|
||||||
<Package className="h-4 w-4 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">Logistics</p>
|
|
||||||
<p className="mt-0.5 text-xs text-gray-500">
|
|
||||||
Rail transport plus documentation, customs liaison, and a
|
|
||||||
dedicated coordinator.
|
|
||||||
</p>
|
|
||||||
<Badge color="edr-green" variant="light" mt="xs" size="sm">
|
|
||||||
Option B
|
|
||||||
</Badge>
|
|
||||||
</OptionCard>
|
|
||||||
</div>
|
</div>
|
||||||
<OptionFieldError error={fieldState.error} />
|
<OptionFieldError error={fieldState.error} />
|
||||||
</div>
|
</div>
|
||||||
@@ -104,112 +100,120 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
|
|||||||
{showServiceSections && (
|
{showServiceSections && (
|
||||||
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
|
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
|
||||||
{/* First Mile */}
|
{/* First Mile */}
|
||||||
<div className="p-4">
|
{includesFirstMile && (
|
||||||
<Controller
|
<div className="p-4">
|
||||||
name="firstMile.enabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">First Mile — Pick-up</p>
|
|
||||||
<p className="mt-0.5 text-xs text-gray-500">
|
|
||||||
Truck pick-up from your premises (Door to Port) to the
|
|
||||||
origin rail yard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.currentTarget.checked;
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("firstMile.pickUpAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="edr-green"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{firstMileEnabled && (
|
|
||||||
<Controller
|
<Controller
|
||||||
name="firstMile.pickUpAddress"
|
name="firstMile.enabled"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field }) => (
|
||||||
<TextInput
|
<div className="flex items-start justify-between gap-4">
|
||||||
{...field}
|
<div className="flex items-start gap-3">
|
||||||
mt="sm"
|
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||||
placeholder="Pick-up address *"
|
<div>
|
||||||
error={fieldState.error?.message}
|
<p className="text-sm font-medium">
|
||||||
radius="md"
|
First Mile — Pick-up
|
||||||
/>
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
Truck pick-up from your premises (Door to Port) to the
|
||||||
|
origin rail yard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.checked;
|
||||||
|
field.onChange(value);
|
||||||
|
if (!value) {
|
||||||
|
form.setValue("firstMile.pickUpAddress", "", {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
{firstMileEnabled && (
|
||||||
</div>
|
<Controller
|
||||||
|
name="firstMile.pickUpAddress"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
mt="sm"
|
||||||
|
placeholder="Pick-up address *"
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Last Mile */}
|
{/* Last Mile */}
|
||||||
<div className="p-4">
|
{includesLastMile && (
|
||||||
<Controller
|
<div className="p-4">
|
||||||
name="lastMile.enabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Last Mile — Delivery</p>
|
|
||||||
<p className="mt-0.5 text-xs text-gray-500">
|
|
||||||
Truck delivery from the destination rail yard to the
|
|
||||||
final address (Port to Door).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.currentTarget.checked;
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("lastMile.deliveryAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
form.setValue("equipmentReturn", "with_return", {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="edr-green"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{lastMileEnabled && (
|
|
||||||
<Controller
|
<Controller
|
||||||
name="lastMile.deliveryAddress"
|
name="lastMile.enabled"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field }) => (
|
||||||
<TextInput
|
<div className="flex items-start justify-between gap-4">
|
||||||
{...field}
|
<div className="flex items-start gap-3">
|
||||||
mt="sm"
|
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||||
placeholder="Delivery address *"
|
<div>
|
||||||
error={fieldState.error?.message}
|
<p className="text-sm font-medium">
|
||||||
radius="md"
|
Last Mile — Delivery
|
||||||
/>
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
Truck delivery from the destination rail yard to the
|
||||||
|
final address (Port to Door).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.checked;
|
||||||
|
field.onChange(value);
|
||||||
|
if (!value) {
|
||||||
|
form.setValue("lastMile.deliveryAddress", "", {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
form.setValue("equipmentReturn", "with_return", {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
{lastMileEnabled && (
|
||||||
</div>
|
<Controller
|
||||||
|
name="lastMile.deliveryAddress"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
mt="sm"
|
||||||
|
placeholder="Delivery address *"
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Equipment Return */}
|
{/* Equipment Return */}
|
||||||
{lastMileEnabled && (
|
{includesLastMile && lastMileEnabled && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Controller
|
<Controller
|
||||||
name="equipmentReturn"
|
name="equipmentReturn"
|
||||||
@@ -228,7 +232,9 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
|
|||||||
checked={field.value === "with_return"}
|
checked={field.value === "with_return"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.currentTarget.checked ? "with_return" : "without_return",
|
e.currentTarget.checked
|
||||||
|
? "with_return"
|
||||||
|
: "without_return",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -240,31 +246,35 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Customs Clearing */}
|
{/* Customs Clearing */}
|
||||||
<div className="p-4">
|
{includesCustoms && (
|
||||||
<Controller
|
<div className="p-4">
|
||||||
name="customsClearingEnabled"
|
<Controller
|
||||||
control={form.control}
|
name="customsClearingEnabled"
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<div className="flex items-start justify-between gap-4">
|
render={({ field }) => (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
<div className="flex items-start gap-3">
|
||||||
<div>
|
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||||
<p className="text-sm font-medium">Customs Clearing Service</p>
|
<div>
|
||||||
<p className="mt-0.5 text-xs text-gray-500">
|
<p className="text-sm font-medium">
|
||||||
EDR handles customs documentation and clearance on your
|
Customs Clearing Service
|
||||||
behalf.
|
</p>
|
||||||
</p>
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
EDR handles customs documentation and clearance on
|
||||||
|
your behalf.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
)}
|
||||||
checked={field.value}
|
/>
|
||||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
</div>
|
||||||
color="edr-green"
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function Step4Route({
|
|||||||
|
|
||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({ value: y.name, label: y.name }));
|
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
@@ -35,15 +35,40 @@ export function Step4Route({
|
|||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const originData = useMemo(
|
const originData = useMemo(
|
||||||
() => yardOptions.filter((o) => o.value !== destinationYard),
|
() => {
|
||||||
|
return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => {
|
||||||
|
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||||
|
if(!dest) return true;
|
||||||
|
const origin = referenceData?.yard.find((y) => y.id === o.value);
|
||||||
|
|
||||||
|
// can't go from Djibouti to Djibouti
|
||||||
|
if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
[yardOptions, destinationYard],
|
[yardOptions, destinationYard],
|
||||||
);
|
);
|
||||||
|
console.log({yardOptions,originYard, destinationYard})
|
||||||
const destData = useMemo(
|
const destData = useMemo(
|
||||||
() => yardOptions.filter((o) => o.value !== originYard),
|
() => {
|
||||||
|
return yardOptions.filter((o) => o.value !== originYard).filter((d) => {
|
||||||
|
|
||||||
|
|
||||||
|
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||||
|
if(!origin) return true;
|
||||||
|
|
||||||
|
const dest = referenceData?.yard.find((y) => y.id === d.value);
|
||||||
|
// can't go from Djibouti to Djibouti
|
||||||
|
// if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
[yardOptions, originYard],
|
[yardOptions, originYard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const direction = getRouteDirection(originYard, destinationYard);
|
const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard));
|
||||||
|
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
@@ -57,7 +82,7 @@ export function Step4Route({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (direction === "domestic") {
|
if (direction === "DOMESTIC") {
|
||||||
form.setValue("shippingLine", "", { shouldDirty: true });
|
form.setValue("shippingLine", "", { shouldDirty: true });
|
||||||
}
|
}
|
||||||
}, [direction]);
|
}, [direction]);
|
||||||
@@ -117,7 +142,7 @@ export function Step4Route({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{direction && direction !== "domestic" && (
|
{direction && direction !== "DOMESTIC" && (
|
||||||
<Controller
|
<Controller
|
||||||
name="shippingLine"
|
name="shippingLine"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
import { Package, Plus, Trash2, Weight } from "lucide-react";
|
||||||
import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Skeleton,
|
||||||
|
InputLabel,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
calcWagons,
|
calcWagons,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import {
|
import {
|
||||||
AlertBox,
|
AlertBox,
|
||||||
@@ -18,7 +24,11 @@ import {
|
|||||||
StepLabel,
|
StepLabel,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step5CargoDetails({
|
export function Step5CargoDetails({
|
||||||
form,
|
form,
|
||||||
@@ -27,12 +37,14 @@ export function Step5CargoDetails({
|
|||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
direction: RouteDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
referenceData?: Freight.BookingReferenceData;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const freightType = form.watch("freightType");
|
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||||
|
const parentId = cargoTypePath[0];
|
||||||
|
const childId = cargoTypePath[1];
|
||||||
const containers = form.watch("containers");
|
const containers = form.watch("containers");
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
@@ -40,32 +52,58 @@ export function Step5CargoDetails({
|
|||||||
name: "containers",
|
name: "containers",
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerTypeOptions = useMemo(() => {
|
const containerTypeOptionsBySize = useMemo(() => {
|
||||||
if (!referenceData?.containers) return [];
|
if (!referenceData?.containers) return new Map<string, string[]>();
|
||||||
return referenceData.containers.flatMap((group) =>
|
return new Map(
|
||||||
group.types.map((t) => t.name),
|
referenceData.containers.map((g) => [g.size, g.types.map((t) => t.name)]),
|
||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentId) {
|
||||||
|
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
|
||||||
|
}
|
||||||
|
}, [parentId]);
|
||||||
|
|
||||||
|
const selectedCommodity = useMemo(() => {
|
||||||
|
if (!referenceData?.cargo_type || !parentId || !childId) return null;
|
||||||
|
const group = referenceData.cargo_type.find((g) => g.id === parentId);
|
||||||
|
return group?.children?.find((c) => c.id === childId) ?? null;
|
||||||
|
}, [referenceData, parentId, childId]);
|
||||||
|
|
||||||
const freightTypeGroups = useMemo(() => {
|
const freightTypeGroups = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type) return [];
|
if (!referenceData?.cargo_type) return [];
|
||||||
return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
|
return referenceData.cargo_type.filter(
|
||||||
|
(g) => g.code !== "CONTAINER" && !/container/i.test(g.name),
|
||||||
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
|
const freightTypeOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
freightTypeGroups.map((g) => ({
|
||||||
|
value: g.id,
|
||||||
|
label: g.name,
|
||||||
|
})),
|
||||||
|
[freightTypeGroups],
|
||||||
|
);
|
||||||
|
|
||||||
const commodityOptions = useMemo(() => {
|
const commodityOptions = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type || !freightType) return [];
|
if (!referenceData?.cargo_type || !parentId) return [];
|
||||||
const group = referenceData.cargo_type.find(
|
const group = referenceData.cargo_type.find((g) => g.id === parentId);
|
||||||
(g) => g.code.toLowerCase() === freightType,
|
return (
|
||||||
|
group?.children?.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
})) ?? []
|
||||||
);
|
);
|
||||||
return group?.children?.map((c) => c.name) ?? [];
|
}, [referenceData, parentId]);
|
||||||
}, [referenceData, freightType]);
|
|
||||||
|
|
||||||
function getOverweightAlert(
|
function getOverweightAlert(
|
||||||
type: "20ft" | "40ft",
|
type: "20ft" | "40ft",
|
||||||
vgm: number,
|
vgm: number,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (type === "20ft" && vgm > 0) {
|
if (type === "20ft" && vgm > 0) {
|
||||||
const limit = direction === "export" ? 25 : 20;
|
const limit = direction === "EXPORT" ? 25 : 20;
|
||||||
if (vgm > limit) {
|
if (vgm > limit) {
|
||||||
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
|
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
|
||||||
}
|
}
|
||||||
@@ -105,7 +143,7 @@ export function Step5CargoDetails({
|
|||||||
|
|
||||||
{/* Cargo Type */}
|
{/* Cargo Type */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<StepLabel>Cargo Type *</StepLabel>
|
<InputLabel>Cargo Type *</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
name="cargoType"
|
name="cargoType"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -116,7 +154,7 @@ export function Step5CargoDetails({
|
|||||||
selected={cargoType === "container"}
|
selected={cargoType === "container"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange("container");
|
field.onChange("container");
|
||||||
form.setValue("freightType", "", { shouldDirty: true });
|
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
|
||||||
@@ -151,7 +189,6 @@ export function Step5CargoDetails({
|
|||||||
|
|
||||||
{/* Weight */}
|
{/* Weight */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<StepLabel>Weight</StepLabel>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="cargoWeight"
|
name="cargoWeight"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -175,51 +212,57 @@ export function Step5CargoDetails({
|
|||||||
{/* Bulk freight type */}
|
{/* Bulk freight type */}
|
||||||
{cargoType === "bulk" && (
|
{cargoType === "bulk" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<StepLabel>Freight Type *</StepLabel>
|
{freightTypeOptions.length > 0 ? (
|
||||||
<Controller
|
|
||||||
name="freightType"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{freightTypeGroups.map((group) => {
|
|
||||||
const val = group.code.toLowerCase();
|
|
||||||
return (
|
|
||||||
<OptionCard
|
|
||||||
key={group.code}
|
|
||||||
selected={freightType === val}
|
|
||||||
onClick={() => {
|
|
||||||
field.onChange(val);
|
|
||||||
form.setValue("bulkCommoditytype", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="font-semibold">{group.name}</p>
|
|
||||||
</OptionCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<OptionFieldError error={fieldState.error} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{freightType && commodityOptions.length > 0 && (
|
|
||||||
<Controller
|
<Controller
|
||||||
name="bulkCommoditytype"
|
name="cargoTypePath.0"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
field={field}
|
field={field}
|
||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Cargo type *"
|
label="Bulk Cargo Type *"
|
||||||
|
placeholder="Select freight type..."
|
||||||
|
data={freightTypeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No freight types available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parentId && commodityOptions.length > 0 && (
|
||||||
|
<Controller
|
||||||
|
name="cargoTypePath.1"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<SelectField
|
||||||
|
field={field}
|
||||||
|
error={fieldState.error}
|
||||||
|
label=""
|
||||||
placeholder="Select type *"
|
placeholder="Select type *"
|
||||||
data={commodityOptions}
|
data={commodityOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedCommodity?.show_free_text_box && (
|
||||||
|
<Controller
|
||||||
|
name="cargoFreeText"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
label="Describe cargo *"
|
||||||
|
placeholder="e.g. Charcoal, Wheat, etc."
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -254,7 +297,13 @@ export function Step5CargoDetails({
|
|||||||
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
|
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="dimmed"
|
||||||
|
tt="uppercase"
|
||||||
|
className="tracking-wide"
|
||||||
|
>
|
||||||
Container {index + 1}
|
Container {index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
{fields.length > 1 && (
|
{fields.length > 1 && (
|
||||||
@@ -282,7 +331,7 @@ export function Step5CargoDetails({
|
|||||||
val: "20ft" as const,
|
val: "20ft" as const,
|
||||||
label: "20ft Container (TEU)",
|
label: "20ft Container (TEU)",
|
||||||
limit:
|
limit:
|
||||||
direction === "export"
|
direction === "EXPORT"
|
||||||
? "Max 25t per container"
|
? "Max 25t per container"
|
||||||
: "Max 20t per container",
|
: "Max 20t per container",
|
||||||
},
|
},
|
||||||
@@ -301,7 +350,9 @@ export function Step5CargoDetails({
|
|||||||
<Package className="h-4 w-4 text-emerald-600" />
|
<Package className="h-4 w-4 text-emerald-600" />
|
||||||
<p className="font-semibold">{ct.label}</p>
|
<p className="font-semibold">{ct.label}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">{ct.limit}</p>
|
<p className="text-xs text-gray-500">
|
||||||
|
{ct.limit}
|
||||||
|
</p>
|
||||||
</OptionCard>
|
</OptionCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,7 +376,10 @@ export function Step5CargoDetails({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
qtyField.onChange(
|
qtyField.onChange(
|
||||||
Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
|
Math.max(
|
||||||
|
1,
|
||||||
|
Number(qtyField.value ?? 1) - 1,
|
||||||
|
).toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
|
||||||
@@ -334,7 +388,9 @@ export function Step5CargoDetails({
|
|||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
value={qtyField.value ?? 1}
|
value={qtyField.value ?? 1}
|
||||||
onChange={(e) => qtyField.onChange(e.target.value)}
|
onChange={(e) =>
|
||||||
|
qtyField.onChange(e.target.value)
|
||||||
|
}
|
||||||
onBlur={qtyField.onBlur}
|
onBlur={qtyField.onBlur}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -389,7 +445,11 @@ export function Step5CargoDetails({
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Container Type *"
|
label="Container Type *"
|
||||||
placeholder="Select type..."
|
placeholder="Select type..."
|
||||||
data={containerTypeOptions}
|
data={
|
||||||
|
containerTypeOptionsBySize.get(
|
||||||
|
containers[index]?.type ?? "20ft",
|
||||||
|
) ?? []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { type UseFormReturn } from "react-hook-form";
|
|
||||||
import { type BookingFormValues, type WagonCalcResult } from "./schema";
|
|
||||||
import { AlertBox, StepHeader, StepLabel } from "./shared";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
|
||||||
|
|
||||||
export function Step6WagonAllocation({
|
|
||||||
form,
|
|
||||||
wagons,
|
|
||||||
}: {
|
|
||||||
form: BookingForm;
|
|
||||||
wagons: WagonCalcResult | null;
|
|
||||||
}) {
|
|
||||||
const containers = form.watch("containers") ?? [];
|
|
||||||
const totalContainers = containers.reduce(
|
|
||||||
(sum, c) => sum + Number(c.qty || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const containerSummary = containers
|
|
||||||
.filter((c) => +c.qty > 0)
|
|
||||||
.map((c) => `${c.qty} × ${c.type}`)
|
|
||||||
.join(", ");
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StepHeader
|
|
||||||
title="Wagon Allocation"
|
|
||||||
description="System-calculated wagon requirements based on your container profile."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!wagons ? (
|
|
||||||
<AlertBox tone="info">
|
|
||||||
Complete the container configuration in the previous step to see wagon
|
|
||||||
allocation.
|
|
||||||
</AlertBox>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-xl bg-primary/5 p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold text-primary">
|
|
||||||
{wagons.totalWagons}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Wagons Required
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-muted p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold">{totalContainers}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{containerSummary || "Containers"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-muted p-4 text-center">
|
|
||||||
<p className="text-3xl font-bold">{wagons.sharedWagons}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Shared Slots</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<StepLabel>Wagon Layout</StepLabel>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{new Array(wagons.ft40Wagons).fill(0).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
|
|
||||||
border-primary/30 bg-primary/5 text-primary `}
|
|
||||||
>
|
|
||||||
1 × 40ft
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{new Array(wagons.sharedWagons).fill(0).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
|
|
||||||
border-amber-300 bg-amber-50 text-amber-700
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
2 × 20ft
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{wagons.hasOddUnit && (
|
|
||||||
<div
|
|
||||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold border-destructive! bg-destructive/10 text-destructive `}
|
|
||||||
>
|
|
||||||
1 × 20ft
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{wagons.hasOddUnit && (
|
|
||||||
<>
|
|
||||||
<AlertBox tone="warning">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">Unpaired 20ft Container</p>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
One 20ft container occupies only half a wagon. The wagon
|
|
||||||
will depart once a co-loader is found to fill the
|
|
||||||
remaining slot, which <strong>may delay departure</strong>{" "}
|
|
||||||
beyond the standard lead time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertBox>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
|
import type { Freight } from "@/types";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function Step8Review({
|
|||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: RouteDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const errors = form.formState.errors;
|
const errors = form.formState.errors;
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { Step1ContractType } from "./step1-contract-type";
|
|||||||
export { Step2ServiceType } from "./step2-service-type";
|
export { Step2ServiceType } from "./step2-service-type";
|
||||||
export { Step4Route } from "./step4-route";
|
export { Step4Route } from "./step4-route";
|
||||||
export { Step5CargoDetails } from "./step5-cargo-details";
|
export { Step5CargoDetails } from "./step5-cargo-details";
|
||||||
|
export { StepScheduling } from "./step-scheduling";
|
||||||
export { StepDocuments } from "./step-documents";
|
export { StepDocuments } from "./step-documents";
|
||||||
export { Step8Review } from "./step8-review";
|
export { Step8Review } from "./step8-review";
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ export const api = {
|
|||||||
"checkPayment",
|
"checkPayment",
|
||||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getBookableSchedules: endpoint<
|
||||||
|
{ originYardId?: string; destinationYardId?: string },
|
||||||
|
Freight.BookableScheduleItem[]
|
||||||
|
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
|
||||||
|
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
consignments: {
|
consignments: {
|
||||||
|
|||||||
@@ -151,4 +151,14 @@ export const bookingsService = {
|
|||||||
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
|
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBookableSchedules: async (
|
||||||
|
query: Freight.BookableSchedulesQuery = {},
|
||||||
|
): Promise<Freight.BookableScheduleItem[]> => {
|
||||||
|
const { data } = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES,
|
||||||
|
{ params: query },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ export const mantineTheme = createTheme({
|
|||||||
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
xs: "12px",
|
xs: "12px",
|
||||||
sm: "13px",
|
sm: "14px",
|
||||||
md: "14px",
|
md: "16px",
|
||||||
lg: "16px",
|
lg: "20px",
|
||||||
xl: "18px",
|
xl: "24px",
|
||||||
},
|
},
|
||||||
|
|
||||||
lineHeights: {
|
lineHeights: {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { BaseEntity } from "../common";
|
import type { BaseEntity } from "../common";
|
||||||
|
|
||||||
export * from "./file_upload_settings";
|
|
||||||
export * from "./dropdown_settings";
|
export * from "./dropdown_settings";
|
||||||
|
export * from "./file_upload_settings";
|
||||||
export * from "./overview";
|
export * from "./overview";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = 'IMPORT',
|
IMPORT = "IMPORT",
|
||||||
EXPORT = 'EXPORT',
|
EXPORT = "EXPORT",
|
||||||
BOTH = 'BOTH',
|
BOTH = "BOTH",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PriorityType {
|
export enum PriorityType {
|
||||||
USD_PAYER = 'USD_PAYER',
|
USD_PAYER = "USD_PAYER",
|
||||||
RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING',
|
RAIL_AND_FORWARDING = "RAIL_AND_FORWARDING",
|
||||||
GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT',
|
GOVERNMENT_ACCOUNT = "GOVERNMENT_ACCOUNT",
|
||||||
HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT',
|
HIGH_VOLUME_SHIPMENT = "HIGH_VOLUME_SHIPMENT",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bonus applied to government bookings so they outrank commercial priority. */
|
/** Bonus applied to government bookings so they outrank commercial priority. */
|
||||||
@@ -26,19 +26,19 @@ export interface GovernmentBookingFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ExceededAction {
|
export enum ExceededAction {
|
||||||
WARNING_ONLY = 'WARNING_ONLY',
|
WARNING_ONLY = "WARNING_ONLY",
|
||||||
HARD_BLOCK = 'HARD_BLOCK',
|
HARD_BLOCK = "HARD_BLOCK",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CalculationMethod {
|
export enum CalculationMethod {
|
||||||
PER_TON = 'PER_TON',
|
PER_TON = "PER_TON",
|
||||||
FLAT_FEE = 'FLAT_FEE',
|
FLAT_FEE = "FLAT_FEE",
|
||||||
PERCENTAGE = 'PERCENTAGE',
|
PERCENTAGE = "PERCENTAGE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FreightType {
|
export enum FreightType {
|
||||||
Container = 'CONTAINER',
|
Container = "CONTAINER",
|
||||||
Bulk = 'BULK',
|
Bulk = "BULK",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
@@ -282,6 +282,9 @@ export interface IBooking extends BaseEntity {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
paymentStatus: PaymentStatus;
|
paymentStatus: PaymentStatus;
|
||||||
|
|
||||||
|
shippingLineId?: string | null;
|
||||||
|
serviceTypeId: string;
|
||||||
|
|
||||||
contractType: "NEW" | "RENEWAL";
|
contractType: "NEW" | "RENEWAL";
|
||||||
previousContractId?: string | null;
|
previousContractId?: string | null;
|
||||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||||
@@ -311,6 +314,11 @@ export interface IBooking extends BaseEntity {
|
|||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
financialTerms?: string | null;
|
financialTerms?: string | null;
|
||||||
|
|
||||||
|
/** When the batch engine picked this booking and opened the pay window. */
|
||||||
|
selectedForBatchAt?: string | null;
|
||||||
|
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||||
|
paymentDeadline?: string | null;
|
||||||
|
|
||||||
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||||
|
|
||||||
versionNumber: number;
|
versionNumber: number;
|
||||||
@@ -390,6 +398,18 @@ export interface BookingReferenceService {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
serviceName: string;
|
||||||
|
description?: string | null | undefined;
|
||||||
|
canBeBookedAlone: boolean;
|
||||||
|
includesFirstMile: boolean;
|
||||||
|
includesLastMile: boolean;
|
||||||
|
includesCustoms: boolean;
|
||||||
|
priorityBonusPoints: number;
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingReferenceShippingLine {
|
export interface BookingReferenceShippingLine {
|
||||||
@@ -420,6 +440,39 @@ export interface BookingReferenceData {
|
|||||||
cargo_type: BookingReferenceCargoTypeGroup[];
|
cargo_type: BookingReferenceCargoTypeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Train Scheduling (bookable schedules) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BookableSchedulesQuery {
|
||||||
|
originYardId?: string;
|
||||||
|
destinationYardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookableScheduleLocomotive {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string | null;
|
||||||
|
readiness: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookableScheduleItem {
|
||||||
|
id: string;
|
||||||
|
scheduleDate: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
routeName: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
locomotive: BookableScheduleLocomotive | null;
|
||||||
|
wagonCount: number;
|
||||||
|
totalWeightTons: number;
|
||||||
|
totalLengthMeters: number;
|
||||||
|
bookingsCount: number;
|
||||||
|
freightType: FreightType | "MIXED" | null;
|
||||||
|
status: TrainScheduleStatus;
|
||||||
|
bookingWindowStatus: ScheduleBookingWindow;
|
||||||
|
maxWagons: number;
|
||||||
|
remainingWagons: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CreateBookingContainerDto {
|
export interface CreateBookingContainerDto {
|
||||||
@@ -429,31 +482,34 @@ export interface CreateBookingContainerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookingDto {
|
export interface CreateBookingDto {
|
||||||
reference?: string;
|
freightShapeValidation?: boolean | undefined;
|
||||||
customerId?: string;
|
reference?: string | undefined;
|
||||||
companyId?: string;
|
isGovernment?: boolean | undefined;
|
||||||
trainId?: string;
|
governmentInstitution?: string | undefined;
|
||||||
|
companyId?: string | undefined;
|
||||||
|
trainId?: string | undefined;
|
||||||
|
trainScheduleId?: string | undefined;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
contractType: "NEW" | "RENEWAL";
|
contractType: string;
|
||||||
previousContractId?: string;
|
previousContractId?: string | undefined;
|
||||||
serviceTypeId: string;
|
serviceTypeId: string;
|
||||||
firstMilePickupAddress?: string;
|
firstMilePickupAddress?: string | undefined;
|
||||||
lastMileDeliveryAddress?: string;
|
lastMileDeliveryAddress?: string | undefined;
|
||||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
equipmentReturn: string;
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
tradeDirection: string;
|
||||||
freightType: FreightType;
|
freightType: string;
|
||||||
cargoTypeId?: string;
|
cargoTypeId?: string | undefined;
|
||||||
cargoFreeText?: string;
|
cargoFreeText?: string | undefined;
|
||||||
shippingLineId?: string;
|
shippingLineId?: string | undefined;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean | undefined;
|
||||||
paymentCurrency: "ETB" | "USD";
|
paymentCurrency: string;
|
||||||
pnrCode?: string;
|
pnrCode?: string | undefined;
|
||||||
startDate?: string;
|
startDate?: string | undefined;
|
||||||
endDate?: string;
|
endDate?: string | undefined;
|
||||||
financialTerms?: string;
|
financialTerms?: string | undefined;
|
||||||
containers?: CreateBookingContainerDto[];
|
containers?: CreateBookingContainerDto[];
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -368,6 +368,9 @@ importers:
|
|||||||
'@edr/tsconfig':
|
'@edr/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../packages/config/tsconfig
|
version: link:../../../packages/config/tsconfig
|
||||||
|
'@hookform/devtools':
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||||
@@ -1638,6 +1641,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
|
'@hookform/devtools@4.4.0':
|
||||||
|
resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
react-dom: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0':
|
'@hookform/resolvers@3.10.0':
|
||||||
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8830,6 +8839,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==}
|
resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
little-state-machine@4.8.1:
|
||||||
|
resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
load-esm@1.0.3:
|
load-esm@1.0.3:
|
||||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||||
engines: {node: '>=13.2.0'}
|
engines: {node: '>=13.2.0'}
|
||||||
@@ -10556,6 +10570,11 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-simple-animate@3.5.3:
|
||||||
|
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
|
||||||
|
peerDependencies:
|
||||||
|
react-dom: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-smooth@4.0.4:
|
react-smooth@4.0.4:
|
||||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -12120,6 +12139,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
use-deep-compare-effect@1.8.1:
|
||||||
|
resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==}
|
||||||
|
engines: {node: '>=10', npm: '>=6'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13'
|
||||||
|
|
||||||
use-isomorphic-layout-effect@1.2.1:
|
use-isomorphic-layout-effect@1.2.1:
|
||||||
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
|
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -13571,6 +13596,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.23
|
hono: 4.12.23
|
||||||
|
|
||||||
|
'@hookform/devtools@4.4.0(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||||
|
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||||
|
'@types/lodash': 4.17.24
|
||||||
|
little-state-machine: 4.8.1(react@19.2.6)
|
||||||
|
lodash: 4.18.1
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
react-simple-animate: 3.5.3(react-dom@19.2.6(react@19.2.6))
|
||||||
|
use-deep-compare-effect: 1.8.1(react@19.2.6)
|
||||||
|
uuid: 8.3.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@18.3.1))':
|
'@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@18.3.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
react-hook-form: 7.77.0(react@18.3.1)
|
react-hook-form: 7.77.0(react@18.3.1)
|
||||||
@@ -23106,6 +23147,10 @@ snapshots:
|
|||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
wrap-ansi: 9.0.2
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
|
little-state-machine@4.8.1(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
load-esm@1.0.3: {}
|
load-esm@1.0.3: {}
|
||||||
|
|
||||||
load-json-file@1.1.0:
|
load-json-file@1.1.0:
|
||||||
@@ -25095,6 +25140,10 @@ snapshots:
|
|||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
'@types/react': 18.3.31
|
'@types/react': 18.3.31
|
||||||
|
|
||||||
|
react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)):
|
||||||
|
dependencies:
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-equals: 5.4.0
|
fast-equals: 5.4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user