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