mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
feat(bookings): add estimated shipment date handling and validation for binding shipment day
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking wizard now captures a NON-BINDING estimated shipment date instead
|
||||||
|
* of the binding scheduledDate. The binding scheduledDate (validated against
|
||||||
|
* open train departures) is set later, at the operation-request step.
|
||||||
|
*/
|
||||||
|
export class AddEstimatedShipmentDate1820000000012
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddEstimatedShipmentDate1820000000012';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS estimated_shipment_date;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -795,6 +795,20 @@ export class BookingTransitionService {
|
|||||||
throw new BadRequestException('A valid schedule date is required');
|
throw new BadRequestException('A valid schedule date is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The binding shipment day must have at least one OPEN departure on the
|
||||||
|
// route — only schedule-backed days are selectable. The batch engine
|
||||||
|
// assigns the specific train within that (route, day) pool later.
|
||||||
|
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
||||||
|
booking.originYardId,
|
||||||
|
booking.destinationYardId,
|
||||||
|
eatDay(date),
|
||||||
|
);
|
||||||
|
if (!hasDeparture) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No departures available on the selected day for this route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
|
|||||||
@@ -317,12 +317,14 @@ export class BookingsService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
} else if (!isGeneralContract) {
|
} else if (dto.scheduledDate) {
|
||||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
// directly). Require that the route has at least one OPEN departure on
|
||||||
// train later. General contracts skip this — they have no shipment date at
|
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||||
// creation; each drawdown order validates its own day.
|
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||||
const day = eatDay(new Date(dto.scheduledDate!));
|
// binding day is chosen later at the operation-request step. General
|
||||||
|
// contracts also skip this (each drawdown order validates its own day).
|
||||||
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
const hasDeparture =
|
const hasDeparture =
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
dto.originYardId,
|
dto.originYardId,
|
||||||
@@ -428,6 +430,9 @@ export class BookingsService {
|
|||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
|
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||||
|
? new Date(dto.estimatedShipmentDate)
|
||||||
|
: null,
|
||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -621,6 +626,8 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||||
|
if (dto.estimatedShipmentDate)
|
||||||
|
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||||
delete updates.containers;
|
delete updates.containers;
|
||||||
@@ -696,6 +703,23 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Return a paginated list of bookings matching the filter. */
|
/** Return a paginated list of bookings matching the filter. */
|
||||||
|
/**
|
||||||
|
* Whether a route has at least one OPEN train departure on the given EAT day.
|
||||||
|
* Used to validate the binding shipment day chosen at the operation-request
|
||||||
|
* step (only days with a schedule are selectable).
|
||||||
|
*/
|
||||||
|
async hasOpenDepartureOnDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
|
|||||||
@@ -155,14 +155,24 @@ export class CreateBookingDto {
|
|||||||
bookingType?: string;
|
bookingType?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The day the customer wants to ship (the pool day key). Required for one-time
|
* The BINDING shipment day (the pool day key), validated against open train
|
||||||
* bookings; omitted for general contracts, which pick the date per order.
|
* departures. Set later at the operation-request step — NOT at booking
|
||||||
|
* creation. Optional here; staff may still pin it directly.
|
||||||
*/
|
*/
|
||||||
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-binding shipment-date estimate captured in the booking wizard. Purely
|
||||||
|
* informational — NOT validated against train departures.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
estimatedShipmentDate?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||||
@IsIn([...CONTRACT_TYPES])
|
@IsIn([...CONTRACT_TYPES])
|
||||||
contractType!: string;
|
contractType!: string;
|
||||||
|
|||||||
@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
|
|||||||
/**
|
/**
|
||||||
* Nullable: general contracts have no shipment date at creation — the date is
|
* Nullable: general contracts have no shipment date at creation — the date is
|
||||||
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
||||||
|
*
|
||||||
|
* NOTE: this is the BINDING shipment day, validated against actual open train
|
||||||
|
* departures. It is set later, when the customer requests the operation — NOT
|
||||||
|
* at booking creation. See estimatedShipmentDate for the non-binding estimate
|
||||||
|
* captured in the booking wizard.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||||
scheduledDate?: Date | null;
|
scheduledDate?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-binding shipment-date estimate captured in the booking wizard. Purely
|
||||||
|
* informational — NOT validated against train departures. The binding
|
||||||
|
* scheduledDate is chosen later at the operation-request step.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
|
||||||
|
estimatedShipmentDate?: Date | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General contracts only: when the ordering window closes, computed from the
|
* General contracts only: when the ordering window closes, computed from the
|
||||||
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||||
|
|||||||
@@ -9,9 +9,24 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
addMonths,
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfMonth,
|
||||||
|
endOfWeek,
|
||||||
|
format,
|
||||||
|
isSameMonth,
|
||||||
|
isToday,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
} from "date-fns";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -86,6 +101,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
|
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<string>("");
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -339,6 +356,32 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<Box mt="lg">
|
||||||
|
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
||||||
|
Choose your shipment day
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="dimmed" mb="sm">
|
||||||
|
Only days with a scheduled departure on your route can be selected.
|
||||||
|
The operations team assigns the specific train for that day.
|
||||||
|
</Text>
|
||||||
|
<OperationDatePicker
|
||||||
|
originYardId={booking.originYard?.id}
|
||||||
|
destinationYardId={booking.destinationYard?.id}
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={setScheduledDate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{proceedMutation.isError && (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||||
|
{proceedMutation.error instanceof Error
|
||||||
|
? proceedMutation.error.message
|
||||||
|
: "Could not request the operation. Please try again."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" mt="lg" gap="sm">
|
<Group justify="flex-end" mt="lg" gap="sm">
|
||||||
{canUpload && (
|
{canUpload && (
|
||||||
<Button
|
<Button
|
||||||
@@ -362,11 +405,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
proceedMutation.mutate(
|
proceedMutation.mutate(
|
||||||
{ id: booking.id },
|
{ id: booking.id, scheduledDate },
|
||||||
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
|
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loading={proceedMutation.isPending}
|
loading={proceedMutation.isPending}
|
||||||
|
disabled={!scheduledDate}
|
||||||
>
|
>
|
||||||
Proceed to operation
|
Proceed to operation
|
||||||
</Button>
|
</Button>
|
||||||
@@ -375,3 +419,202 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact month calendar for picking the binding shipment day at the
|
||||||
|
* operation-request step. Only days that have an OPEN scheduled departure on the
|
||||||
|
* booking route are selectable; all other days are disabled.
|
||||||
|
*/
|
||||||
|
function OperationDatePicker({
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
originYardId?: string;
|
||||||
|
destinationYardId?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (date: string) => void;
|
||||||
|
}) {
|
||||||
|
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||||
|
|
||||||
|
const { data: availableDays, isLoading } = useQuery(
|
||||||
|
api.bookings.getAvailableDays.queryOptions({
|
||||||
|
input: { originYardId, destinationYardId },
|
||||||
|
enabled: !!originYardId && !!destinationYardId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const departureDays = useMemo(
|
||||||
|
() => new Set(availableDays ?? []),
|
||||||
|
[availableDays],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
|
||||||
|
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
|
||||||
|
return eachDayOfInterval({ start, end }).map((date) => {
|
||||||
|
const dateString = format(date, "yyyy-MM-dd");
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dateString,
|
||||||
|
day: date.getDate(),
|
||||||
|
inMonth: isSameMonth(date, month),
|
||||||
|
today: isToday(date),
|
||||||
|
selected: value === dateString,
|
||||||
|
hasDeparture: departureDays.has(dateString),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [month, departureDays, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
border: "1px solid #E6ECF2",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
maxWidth: 340,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
px={6}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setMonth((m) => addMonths(m, -1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</Button>
|
||||||
|
<Text fz="13px" fw={700} c="#10202F">
|
||||||
|
{format(month, "MMMM yyyy")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
px={6}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setMonth((m) => addMonths(m, 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md" gap={8}>
|
||||||
|
<CalendarIcon size={15} color="#9AA8B5" />
|
||||||
|
<Text fz="12px" c="dimmed">
|
||||||
|
Loading available days…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
ta="center"
|
||||||
|
fz="10px"
|
||||||
|
fw={700}
|
||||||
|
c="#9AA8B5"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map((c) => {
|
||||||
|
const clickable = c.hasDeparture && c.inMonth;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.dateString}
|
||||||
|
type="button"
|
||||||
|
disabled={!clickable}
|
||||||
|
onClick={() => clickable && onChange(c.dateString)}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: c.selected ? 800 : 600,
|
||||||
|
cursor: clickable ? "pointer" : "default",
|
||||||
|
border: c.selected
|
||||||
|
? "1.5px solid #12B981"
|
||||||
|
: clickable
|
||||||
|
? "1px solid #CDEBDD"
|
||||||
|
: "1px solid transparent",
|
||||||
|
background: c.selected
|
||||||
|
? "#12B981"
|
||||||
|
: clickable
|
||||||
|
? "#F4FBF7"
|
||||||
|
: "transparent",
|
||||||
|
color: c.selected
|
||||||
|
? "#fff"
|
||||||
|
: !c.inMonth
|
||||||
|
? "#CBD5E1"
|
||||||
|
: clickable
|
||||||
|
? "#0A6F4D"
|
||||||
|
: "#C4CDD6",
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.day}
|
||||||
|
{c.hasDeparture && c.inMonth && !c.selected && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 4,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#12B981",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{c.selected && (
|
||||||
|
<Check
|
||||||
|
size={11}
|
||||||
|
color="#fff"
|
||||||
|
strokeWidth={3}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 3,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
{value && (
|
||||||
|
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
|
||||||
|
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!isLoading && departureDays.size === 0 && (
|
||||||
|
<Text fz="12px" c="orange.7" mt="sm">
|
||||||
|
No scheduled departures found for this route yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -422,13 +422,14 @@ export default function NewBookingPage() {
|
|||||||
bookingType: isContract
|
bookingType: isContract
|
||||||
? Freight.BookingType.GeneralContract
|
? Freight.BookingType.GeneralContract
|
||||||
: Freight.BookingType.OneTime,
|
: Freight.BookingType.OneTime,
|
||||||
// General contracts omit the shipment date — chosen per order later.
|
// The wizard captures a NON-BINDING estimate only — never the binding
|
||||||
...(isContract
|
// scheduledDate (that is chosen later at the operation-request step and
|
||||||
|
// validated against open departures). General contracts omit even the
|
||||||
|
// estimate; the date is chosen per order later.
|
||||||
|
...(isContract || !data.scheduledDate
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
scheduledDate: data.scheduledDate
|
estimatedShipmentDate: new Date(data.scheduledDate).toISOString(),
|
||||||
? new Date(data.scheduledDate).toISOString()
|
|
||||||
: new Date().toISOString(),
|
|
||||||
}),
|
}),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { FileText, Info, Layers, Train, Truck } from "lucide-react";
|
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||||
import {
|
import {
|
||||||
fieldStyles,
|
fieldStyles,
|
||||||
OptionCard,
|
|
||||||
OptionFieldError,
|
OptionFieldError,
|
||||||
StepCard,
|
StepCard,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
@@ -88,17 +87,14 @@ export function Step2ServiceType({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{referenceData?.service
|
{referenceData?.service
|
||||||
.filter((s) => s.canBeBookedAlone)
|
.filter((s) => s.canBeBookedAlone)
|
||||||
.map((s) => (
|
.map((s) => (
|
||||||
<OptionCard
|
<ServiceTypeCard
|
||||||
key={s.id}
|
key={s.id}
|
||||||
selected={field.value === s.id}
|
selected={field.value === s.id}
|
||||||
onClick={() => field.onChange(s.id)}
|
onClick={() => field.onChange(s.id)}
|
||||||
icon={<Train className="h-5 w-5" />}
|
|
||||||
iconBg="#EEF0FB"
|
|
||||||
iconColor="#4F46E5"
|
|
||||||
title={s.serviceName}
|
title={s.serviceName}
|
||||||
description={s.description}
|
description={s.description}
|
||||||
/>
|
/>
|
||||||
@@ -365,6 +361,92 @@ export function Step2ServiceType({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact service-type selection card. A single horizontal row (icon · text ·
|
||||||
|
* radio) — deliberately smaller than the shared OptionCard so the service list
|
||||||
|
* stays scannable.
|
||||||
|
*/
|
||||||
|
function ServiceTypeCard({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
title?: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "12px 14px",
|
||||||
|
transition: "all 140ms ease",
|
||||||
|
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
|
||||||
|
background: selected ? "#F4FBF7" : "#fff",
|
||||||
|
boxShadow: selected
|
||||||
|
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
|
||||||
|
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={11} align="center" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 9,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: selected ? "#E3F4EC" : "#EEF0FB",
|
||||||
|
color: selected ? "#0A6F4D" : "#4F46E5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Train size={17} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text fz={13.5} fw={700} c="#10202F" truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: selected ? "none" : "1.5px solid #CBD5E1",
|
||||||
|
background: selected ? "#12B981" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ServiceToggle({
|
function ServiceToggle({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -269,10 +269,14 @@ export const api = {
|
|||||||
bookingsService.submitClearanceDocuments(id, files),
|
bookingsService.submitClearanceDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
|
proceedToOperation: endpoint<
|
||||||
|
{ id: string; scheduledDate: string },
|
||||||
|
Freight.IBooking
|
||||||
|
>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"proceedToOperation",
|
"proceedToOperation",
|
||||||
({ id }) => bookingsService.proceedToOperation(id),
|
({ id, scheduledDate }) =>
|
||||||
|
bookingsService.proceedToOperation(id, scheduledDate),
|
||||||
),
|
),
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
|
|||||||
@@ -206,8 +206,14 @@ export const bookingsService = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
|
proceedToOperation: async (
|
||||||
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
|
id: string,
|
||||||
|
scheduledDate: string,
|
||||||
|
): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await client.post(
|
||||||
|
`/api/bookings/${id}/clearance/proceed`,
|
||||||
|
{ scheduledDate },
|
||||||
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -646,8 +646,10 @@ export interface CreateBookingDto {
|
|||||||
companyId?: string | undefined;
|
companyId?: string | undefined;
|
||||||
trainId?: string | undefined;
|
trainId?: string | undefined;
|
||||||
trainScheduleId?: string | undefined;
|
trainScheduleId?: string | undefined;
|
||||||
/** Optional for general contracts — they pick the date per order, not at creation. */
|
/** Binding shipment day — set at the operation-request step, not at creation. */
|
||||||
scheduledDate?: string | undefined;
|
scheduledDate?: string | undefined;
|
||||||
|
/** Non-binding shipment-date estimate captured in the booking wizard. */
|
||||||
|
estimatedShipmentDate?: string | undefined;
|
||||||
/** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */
|
/** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */
|
||||||
bookingType?: BookingType | undefined;
|
bookingType?: BookingType | undefined;
|
||||||
contractType: string;
|
contractType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user