fix conflict

This commit is contained in:
hagiye
2026-06-25 11:31:50 +03:00
168 changed files with 8445 additions and 2583 deletions

View File

@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
const flow = useBookingActionDialog(row.id, context);
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else if (isClearanceNavAction(action.id)) {
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else {

View File

@@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
ClipboardList,
FileSignature,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
@@ -21,7 +23,9 @@ const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
intake: <Inbox size={17} strokeWidth={1.85} />,
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
payment: <Wallet size={17} strokeWidth={1.85} />,
ops_review: <ClipboardList size={17} strokeWidth={1.85} />,
operations: <Train size={17} strokeWidth={1.85} />,
completed: <CheckCircle size={17} strokeWidth={1.85} />,
closed: <XCircle size={17} strokeWidth={1.85} />,

View File

@@ -1,4 +1,4 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Train, MapPin, ArrowRight, FileText } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
@@ -44,6 +44,8 @@ export function BookingRouteServiceCard({
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const includesCustoms = booking.serviceType?.includesCustoms;
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
@@ -96,6 +98,47 @@ export function BookingRouteServiceCard({
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
{includesCustoms ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#0A6F4D" />
<Text fz={13} fw={600} c="#0A6F4D">
Customs clearing included automatically
</Text>
</Group>
</Box>
) : booking.customsClearingAgent ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #E6ECF2",
background: "#F8FAFC",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#64748B" />
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
</Text>
</Text>
</Group>
</Box>
) : null}
</SectionCard>
);
}

View File

@@ -0,0 +1,540 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
Textarea,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
FileCheck2,
FileText,
MessageSquareWarning,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
export interface ClearanceReviewSectionProps {
bookingId: string;
/** Called after any review/finalize mutation so the parent can refetch. */
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
}
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "gray" },
};
/**
* Staff-facing clearance document review: approve / query each customer
* document, upload customs output documents (customs bookings only) and
* finalize once every required document is approved. Shared by the Global
* Logistics clearance detail page (customs) and the Marketing booking detail
* (non-customs) — the only difference is the output-docs block, which renders
* only when the booking has a customs output set.
*/
export function ClearanceReviewSection({
bookingId,
onChanged,
hideSummary,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const { data: clearance, isLoading } = useQuery({
queryKey: ["clearance", bookingId],
queryFn: () => bookingsService.getClearance(bookingId),
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["clearance", bookingId] });
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
onChanged?.();
};
const reviewMutation = useMutation({
mutationFn: (p: {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) => bookingsService.reviewClearanceDocument(bookingId, p),
onSuccess: (_d, p) => {
toast.success(
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
);
if (p.status === "QUERIED")
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
refresh();
},
onError: () => toast.error("Could not update document"),
});
const outputMutation = useMutation({
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
refresh();
},
onError: () => toast.error("Upload failed"),
});
const finalizeMutation = useMutation({
mutationFn: () => bookingsService.finalizeClearance(bookingId),
onSuccess: () => {
toast.success("Clearance finalized");
refresh();
},
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "Could not finalize clearance",
),
});
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
(d) => d.reviewStatus === "APPROVED",
).length;
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [customerDocs]);
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
{clearance.outputCode && (
<SectionCard
icon={Upload}
title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
</Group>
</Group>
))}
</Stack>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={outputMutation.isPending}
onClick={() => outputMutation.mutate()}
>
Upload output documents
</Button>
</Group>
</SectionCard>
)}
{finalizeMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error
? finalizeMutation.error.message
: "Could not finalize clearance."}
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Paper>
</Stack>
);
}
function StatPill({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="12.5px" c="edr-text" fw={600}>
{value}
</Text>
<Text fz="12.5px" c="dimmed">
{label}
</Text>
</Group>
);
}
function DocReviewCard({
doc,
note,
queryOpen,
onToggleQuery,
onNote,
onApprove,
onQuery,
busy,
}: {
doc: Freight.ClearanceDocument;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
return (
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile && (
<Tooltip label="Open document">
<Button
component="a"
href={doc.file!.url}
target="_blank"
rel="noreferrer"
size="compact-xs"
variant="default"
radius="md"
leftSection={<ExternalLink size={13} />}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{hasFile && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
</Group>
) : (
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
<Textarea
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
value={note}
onChange={(e) => onNote(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
size="sm"
autoFocus
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => onToggleQuery(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
loading={busy}
disabled={!note.trim()}
onClick={onQuery}
>
Send query to customer
</Button>
</Group>
</Box>
)}
</Box>
)}
</Paper>
);
}

View File

@@ -0,0 +1,260 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Box,
Card,
Center,
Group,
Loader,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageCheck } from "lucide-react";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
useContractOrders,
useContractPool,
} from "@/hooks/bookings/useContractOrders";
import type { Freight } from "@edr/types";
export interface ContractOrdersPanelProps {
/** The general-contract booking whose drawdown orders are listed. */
contractBookingId: string;
/** Whether the contract is container-based (affects quantity labels). */
isContainer: boolean;
}
/** Format a contracted/remaining quantity with its unit. */
function formatQuantity(
qty: number,
unit: Freight.ContractQuantityLine["unitOfMeasure"],
isContainerLine: boolean,
): string {
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
if (isContainerLine) return `${rounded} containers`;
if (unit === "PER_ITEM") return `${rounded} items`;
return `${rounded} tons`;
}
/** Summarise an order's lines, e.g. "2 20FT, 1 40FT" or "15". */
function summariseLines(lines: Freight.IBookingOrderLine[]): string {
return lines
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty) ? `${qty}` : qty.toFixed(2);
return `${label}${l.containerTypeName ? ` ${l.containerTypeName}` : ""}`;
})
.join(", ");
}
/**
* Backoffice "Orders" tab for a general contract: shows the drawdown pool and
* lists each order placed against the contract. Each order links to its child
* booking's detail page, where staff approve it and review clearance/customer
* documents independently (same screen as a one-time booking).
*/
export function ContractOrdersPanel({
contractBookingId,
isContainer,
}: ContractOrdersPanelProps) {
const navigate = useNavigate();
const { data: orders, isLoading: ordersLoading } =
useContractOrders(contractBookingId);
const { data: pool, isLoading: poolLoading } =
useContractPool(contractBookingId);
const poolLines = pool ?? [];
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
if (ordersLoading || poolLoading) {
return (
<Center mih={240}>
<Loader color="gray" />
</Center>
);
}
return (
<Stack gap="lg">
{/* Drawdown pool */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
<Box>
<Text fw={700} fz={16}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mt={2}>
How much of this contract has been ordered versus what remains.
</Text>
</Box>
{totals.contracted > 0 && (
<RingProgress
size={72}
thickness={7}
roundCaps
sections={[{ value: totals.pct, color: "edr-green" }]}
label={
<Text ta="center" fz={13} fw={800}>
{totals.pct}%
</Text>
}
/>
)}
</Group>
<Stack gap="lg">
{poolLines.length === 0 && (
<Text fz={13} c="dimmed">
No quantity pool available.
</Text>
)}
{poolLines.map((line, i) => {
const pct =
line.contractedQuantity > 0
? Math.min(
100,
(line.orderedQuantity / line.contractedQuantity) * 100,
)
: 0;
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
const depleted = line.remainingQuantity <= 0;
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Group gap={8} align="center">
<Text fz={14} fw={600}>
{label}
</Text>
{depleted && (
<Badge size="xs" variant="light" color="gray" radius="sm">
Fully ordered
</Badge>
)}
</Group>
<Text fz={13} c="dimmed">
<Text span fw={700} c={depleted ? "dimmed" : "edr-green"}>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>{" "}
remaining of{" "}
{formatQuantity(
line.contractedQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>
</Group>
<Progress
value={pct}
color={depleted ? "gray" : "edr-green"}
size="md"
radius="xl"
/>
</div>
);
})}
</Stack>
</Card>
{/* Orders */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="center" mb="md">
<Text fw={700} fz={16}>
Orders
</Text>
<Badge variant="light" color="violet" radius="sm">
{orders?.length ?? 0}
</Badge>
</Group>
{!orders || orders.length === 0 ? (
<Stack align="center" gap={8} py="xl">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<Inbox size={22} />
</ThemeIcon>
<Text fz={13} c="dimmed" ta="center" maw={360}>
No orders have been placed against this contract yet.
</Text>
</Stack>
) : (
<Stack gap={10}>
{orders.map((order) => {
const childId = order.bookingId;
const clickable = Boolean(childId);
return (
<Group
key={order.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-gray-3)",
cursor: clickable ? "pointer" : "default",
}}
onClick={
clickable
? () =>
navigate(`/dashboard/booking-requests/${childId}`)
: undefined
}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
>
<PackageCheck size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} truncate>
{order.reference}
</Text>
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(order.scheduledDate).toLocaleDateString()}
{order.lines.length > 0
? ` · ${summariseLines(order.lines)}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<BookingStatusBadge status={order.status} />
{clickable && (
<ChevronRight
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</Group>
</Group>
);
})}
</Stack>
)}
</Card>
</Stack>
);
}

View File

@@ -1,5 +1,7 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";
export * from "./BookingDetailHeader";

View File

@@ -8,6 +8,7 @@ import {
Checkbox,
Group,
Modal,
MultiSelect,
Paper,
Radio,
RingProgress,
@@ -107,7 +108,7 @@ export function AllocateBookingWizard({
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const scheduleDate = booking.scheduledDate;
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
@@ -152,7 +153,7 @@ export function AllocateBookingWizard({
useEffect(() => {
if (scheduleMode === "new") {
setLocomotiveId("");
setLocomotiveIds([]);
}
}, [routeId, scheduleMode]);
@@ -264,11 +265,11 @@ export function AllocateBookingWizard({
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
throw new Error("Select route, date, and locomotive");
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives");
}
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
setSelectedScheduleId(created.id);
return created.id;
@@ -526,17 +527,25 @@ export function AllocateBookingWizard({
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="At least two (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}