mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -7,6 +7,7 @@ import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isContractNavAction,
|
||||
listRowHasActions,
|
||||
type BookingActionContext,
|
||||
@@ -20,12 +21,14 @@ interface BookingActionsMenuProps {
|
||||
className?: string;
|
||||
/** Suppresses table row navigation after menu/dialog close (click-through). */
|
||||
onSuppressRowClick?: () => void;
|
||||
onAllocateBooking?: () => void;
|
||||
}
|
||||
|
||||
export function BookingActionsMenu({
|
||||
row,
|
||||
variant = "table",
|
||||
onSuppressRowClick,
|
||||
onAllocateBooking,
|
||||
}: BookingActionsMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -34,6 +37,7 @@ export function BookingActionsMenu({
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -46,6 +50,8 @@ export function BookingActionsMenu({
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else if (isAllocateAction(action.id)) {
|
||||
onAllocateBooking?.();
|
||||
} else {
|
||||
flow.openAction(action);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
@@ -18,6 +21,7 @@ interface BookingActionsToolbarProps {
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
@@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
<BookingActionsMenu
|
||||
row={row}
|
||||
variant="toolbar"
|
||||
onAllocateBooking={() => setAllocateOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => setAllocateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Building2, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
||||
|
||||
function BookingQueueRow({
|
||||
booking,
|
||||
selected,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
booking: BookingListRow;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">{booking.reference}</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
||||
{booking.schedulingStatus ? (
|
||||
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="xs">{booking.originLabel}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs">{booking.destinationLabel}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.serviceTypeLabel ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.serviceTypeLabel}
|
||||
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationsBookingQueue({
|
||||
bookings,
|
||||
isLoading,
|
||||
onAllocate,
|
||||
}: {
|
||||
bookings: BookingListRow[];
|
||||
isLoading?: boolean;
|
||||
onAllocate: (bookingIds: string[]) => void;
|
||||
}) {
|
||||
const { government, commercial } = useMemo(
|
||||
() => groupBookingsForOperationsQueue(bookings),
|
||||
[bookings],
|
||||
);
|
||||
const [govSelected, setGovSelected] = useState<string[]>([]);
|
||||
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
||||
|
||||
const allocatable = (row: BookingListRow) =>
|
||||
row.status === "PAID" &&
|
||||
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
||||
|
||||
const govSelection = govSelected.length
|
||||
? govSelected
|
||||
: government.filter(allocatable).map((b) => b.id);
|
||||
|
||||
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
||||
const existing = selectedByBucket[bucketKey];
|
||||
if (existing) return existing;
|
||||
return bucketBookings.filter(allocatable).map((b) => b.id);
|
||||
};
|
||||
|
||||
const toggleGov = (bookingId: string) => {
|
||||
setGovSelected((prev) => {
|
||||
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
||||
return base.includes(bookingId)
|
||||
? base.filter((id) => id !== bookingId)
|
||||
: [...base, bookingId];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
||||
setSelectedByBucket((prev) => {
|
||||
const current = prev[bucketKey] ?? [];
|
||||
const next = current.includes(bookingId)
|
||||
? current.filter((id) => id !== bookingId)
|
||||
: [...current, bookingId];
|
||||
return { ...prev, [bucketKey]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
||||
}
|
||||
|
||||
if (!government.length && !commercial.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No PAID bookings ready to allocate.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{government.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={5}>Government priority</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Served first — not grouped by 3-hour window
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{govSelection.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="violet"
|
||||
disabled={!govSelection.length}
|
||||
onClick={() => onAllocate(govSelection)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{government.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={govSelection.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleGov(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{commercial.length > 0 ? (
|
||||
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
||||
{commercial.map((bucket) => {
|
||||
const selected = bucketSelection(bucket.key, bucket.bookings);
|
||||
return (
|
||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">{bucket.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bucket.bookings.length} commercial booking
|
||||
{bucket.bookings.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{selected.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
disabled={!selected.length}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocate(selected);
|
||||
}}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{bucket.bookings.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={selected.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
export function OperationsScheduledBookings({
|
||||
bookings,
|
||||
isLoading,
|
||||
}: {
|
||||
bookings: BookingListRow[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6}>
|
||||
<Text fw={600} size="sm">{row.original.reference}</Text>
|
||||
{row.original.isGovernment ? (
|
||||
<Badge color="violet" size="xs">Government</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{row.original.customerLabel}</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6}>
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: "Scheduled",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{String(row.original.scheduledDate).slice(0, 16)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Scheduling",
|
||||
cell: ({ row }) =>
|
||||
row.original.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={row.original.schedulingStatus} />
|
||||
) : (
|
||||
<Badge variant="light">—</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${row.original.id}`}
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
>
|
||||
View booking
|
||||
</Button>
|
||||
{row.original.trainScheduleId ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${row.original.trainScheduleId}`}
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
>
|
||||
Train schedule
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={bookings}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No bookings currently assigned to a train schedule"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
|
||||
import { detailStyles, formatDate } from "./booking-detail.styles";
|
||||
@@ -52,7 +53,15 @@ export function BookingRequestHero({
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{booking.nextStep && (
|
||||
<Box maw={520}>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useContainers, useAssignContainerToWagon } from './use-containers';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
export function AssignContainerDialog({ wagonId }: { wagonId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [containerId, setContainerId] = useState('');
|
||||
const [position, setPosition] = useState<number>();
|
||||
const { data: containers } = useContainers();
|
||||
const assign = useAssignContainerToWagon();
|
||||
const { toast } = useToast();
|
||||
|
||||
const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!containerId) return;
|
||||
await assign.mutateAsync({ containerId, wagonId, position });
|
||||
toast({ title: 'Assigned', description: 'Container placed on wagon.' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Container</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Assign Container to Wagon</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Container</Label><Select value={containerId} onValueChange={setContainerId}><SelectTrigger><SelectValue placeholder="Select container" /></SelectTrigger><SelectContent>{available?.map(c => <SelectItem key={c.id} value={c.id}>{c.containerNumber}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div><Label>Position (optional)</Label><Input type="number" value={position ?? ''} onChange={e => setPosition(parseInt(e.target.value) || undefined)} /></div>
|
||||
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCargoTypes } from './use-cargo-types';
|
||||
import { useCargoMutations } from './use-cargoes';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
cargoNumber: string;
|
||||
cargoTypeId: string;
|
||||
weight: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface CargoFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cargo?: Cargo | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
cargo,
|
||||
onSuccess,
|
||||
}: CargoFormDialogProps) {
|
||||
const { data: cargoTypes } = useCargoTypes();
|
||||
const { createCargo, updateCargo } = useCargoMutations();
|
||||
const [formData, setFormData] = useState<Partial<Cargo>>({
|
||||
cargoNumber: '',
|
||||
cargoTypeId: '',
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cargo) {
|
||||
setFormData(cargo);
|
||||
} else {
|
||||
setFormData({
|
||||
cargoNumber: '',
|
||||
cargoTypeId: '',
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
}
|
||||
}, [cargo, open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cargo?.id) {
|
||||
updateCargo.mutate(
|
||||
{ id: cargo.id, data: formData },
|
||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
||||
);
|
||||
} else {
|
||||
createCargo.mutate(formData, {
|
||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createCargo.isPending || updateCargo.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Cargo Number *</Label>
|
||||
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Cargo Type *</Label>
|
||||
<Select
|
||||
value={formData.cargoTypeId || ''}
|
||||
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select cargo type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cargoTypes?.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Weight (kg) *</Label>
|
||||
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Remarks</Label>
|
||||
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useContainerTypes } from './use-container-types';
|
||||
import { useContainerMutations } from './use-containers';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Container {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
containerTypeId: string;
|
||||
wagonId?: string;
|
||||
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
|
||||
capacity: number;
|
||||
weight: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface Wagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
}
|
||||
|
||||
interface ContainerFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
container?: Container | null;
|
||||
wagons: Wagon[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function ContainerFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
container,
|
||||
wagons = [],
|
||||
onSuccess,
|
||||
}: ContainerFormDialogProps) {
|
||||
const { data: containerTypes } = useContainerTypes();
|
||||
const { createContainer, updateContainer } = useContainerMutations();
|
||||
const [formData, setFormData] = useState<Partial<Container>>({
|
||||
containerNumber: '',
|
||||
containerTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (container) {
|
||||
setFormData(container);
|
||||
} else {
|
||||
setFormData({
|
||||
containerNumber: '',
|
||||
containerTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
}
|
||||
}, [container, open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.containerNumber || !formData.containerTypeId) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (container?.id) {
|
||||
updateContainer.mutate(
|
||||
{ id: container.id, data: formData },
|
||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
||||
);
|
||||
} else {
|
||||
createContainer.mutate(formData, {
|
||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createContainer.isPending || updateContainer.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{container ? 'Edit Container' : 'Create New Container'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="containerNumber">Container Number *</Label>
|
||||
<Input
|
||||
id="containerNumber"
|
||||
value={formData.containerNumber || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, containerNumber: e.target.value })
|
||||
}
|
||||
placeholder="e.g., CNT001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="containerTypeId">Container Type *</Label>
|
||||
<Select
|
||||
value={formData.containerTypeId || ''}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
|
||||
>
|
||||
<SelectTrigger id="containerTypeId">
|
||||
<SelectValue placeholder="Select container type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containerTypes?.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="wagonId">Wagon (Optional)</Label>
|
||||
<Select
|
||||
value={formData.wagonId || 'none'}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
|
||||
>
|
||||
<SelectTrigger id="wagonId">
|
||||
<SelectValue placeholder="Select a wagon..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{wagons.map((wagon) => (
|
||||
<SelectItem key={wagon.id} value={wagon.id}>
|
||||
{wagon.wagonNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status || 'AVAILABLE'}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AVAILABLE">Available</SelectItem>
|
||||
<SelectItem value="IN_USE">In Use</SelectItem>
|
||||
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
|
||||
<SelectItem value="RETIRED">Retired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Capacity *</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
value={formData.capacity || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
capacity: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="weight">Weight (kg)</Label>
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
value={formData.weight || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Textarea
|
||||
id="remarks"
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, remarks: e.target.value })
|
||||
}
|
||||
placeholder="Add any additional notes..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{container ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useContainersByWagon, useUnassignContainer } from './use-containers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { Container } from './container.service';
|
||||
|
||||
export function ContainersTable({ wagonId }: { wagonId: string }) {
|
||||
const { data: containers, refetch } = useContainersByWagon(wagonId);
|
||||
const unassign = useUnassignContainer();
|
||||
|
||||
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
|
||||
|
||||
return (
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Number</th>
|
||||
<th className="text-left">Type</th>
|
||||
<th className="text-left">Position</th>
|
||||
<th className="text-left">Status</th>
|
||||
<th className="text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((container: Container) => (
|
||||
<tr key={container.id}>
|
||||
<td className="py-2">{container.containerNumber}</td>
|
||||
<td className="py-2">{container.containerTypeId}</td>
|
||||
<td className="py-2">{container.position}</td>
|
||||
<td className="py-2">{container.status}</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const cargoTypesService = {
|
||||
async getCargoTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
|
||||
params: { isActive: true, pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const cargoService = {
|
||||
async getCargoes() {
|
||||
const response = await api.get('/cargoes');
|
||||
return response.data;
|
||||
},
|
||||
async createCargo(data: any) {
|
||||
const response = await api.post('/cargoes', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateCargo(id: string, data: any) {
|
||||
const response = await api.patch(`/cargoes/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteCargo(id: string) {
|
||||
await api.delete(`/cargoes/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const containerTypesService = {
|
||||
async getContainerTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/container-types', {
|
||||
params: { isActive: true, pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const containerService = {
|
||||
async getContainers() {
|
||||
const response = await api.get('/containers');
|
||||
return response.data;
|
||||
},
|
||||
async getContainersByWagon(wagonId: string) {
|
||||
const response = await api.get('/containers', { params: { wagonId } });
|
||||
return response.data;
|
||||
},
|
||||
async createContainer(data: any) {
|
||||
const response = await api.post('/containers', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateContainer(id: string, data: any) {
|
||||
const response = await api.patch(`/containers/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteContainer(id: string) {
|
||||
await api.delete(`/containers/${id}`);
|
||||
},
|
||||
async assignToWagon(containerId: string, wagonId: string, position?: number) {
|
||||
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
|
||||
return response.data;
|
||||
},
|
||||
async unassignFromWagon(containerId: string) {
|
||||
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { cargoTypesService } from './cargo-types.service';
|
||||
|
||||
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
|
||||
|
||||
export function useCargoTypes() {
|
||||
return useQuery({
|
||||
queryKey: CARGO_TYPES_QUERY_KEY,
|
||||
queryFn: () => cargoTypesService.getCargoTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { cargoService } from './cargo.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const CARGOES_QUERY_KEY = ['cargoes'];
|
||||
|
||||
export function useCargoes() {
|
||||
return useQuery({
|
||||
queryKey: CARGOES_QUERY_KEY,
|
||||
queryFn: () => cargoService.getCargoes(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCargoMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createCargo = useMutation({
|
||||
mutationFn: (data: any) => cargoService.createCargo(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo created successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const updateCargo = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo updated successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCargo = useMutation({
|
||||
mutationFn: (id: string) => cargoService.deleteCargo(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createCargo, updateCargo, deleteCargo };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { containerTypesService } from './container-types.service';
|
||||
|
||||
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
|
||||
|
||||
export function useContainerTypes() {
|
||||
return useQuery({
|
||||
queryKey: CONTAINER_TYPES_QUERY_KEY,
|
||||
queryFn: () => containerTypesService.getContainerTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { containerService } from './container.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const CONTAINERS_QUERY_KEY = ['containers'];
|
||||
|
||||
export function useContainers() {
|
||||
return useQuery({
|
||||
queryKey: CONTAINERS_QUERY_KEY,
|
||||
queryFn: () => containerService.getContainers(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContainersByWagon(wagonId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...CONTAINERS_QUERY_KEY, 'wagon', wagonId],
|
||||
queryFn: () => containerService.getContainersByWagon(wagonId),
|
||||
enabled: !!wagonId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContainerMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createContainer = useMutation({
|
||||
mutationFn: (data: any) => containerService.createContainer(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container created successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const updateContainer = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => containerService.updateContainer(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container updated successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteContainer = useMutation({
|
||||
mutationFn: (id: string) => containerService.deleteContainer(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createContainer, updateContainer, deleteContainer };
|
||||
}
|
||||
|
||||
export function useUnassignContainer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => containerService.unassignFromWagon(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container unassigned from wagon');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignContainerToWagon() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ containerId, wagonId, position }: { containerId: string; wagonId: string; position?: number }) =>
|
||||
containerService.assignToWagon(containerId, wagonId, position),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container assigned to wagon');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { wagonTypesService } from './wagon-types.service';
|
||||
|
||||
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
|
||||
|
||||
export function useWagonTypes() {
|
||||
return useQuery({
|
||||
queryKey: WAGON_TYPES_QUERY_KEY,
|
||||
queryFn: () => wagonTypesService.getWagonTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { wagonService } from './wagon.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const WAGONS_QUERY_KEY = ['wagons'];
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({
|
||||
queryKey: WAGONS_QUERY_KEY,
|
||||
queryFn: () => wagonService.getWagons(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWagonMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createWagon = useMutation({
|
||||
mutationFn: (data: any) => wagonService.createWagon(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to create wagon');
|
||||
},
|
||||
});
|
||||
|
||||
const updateWagon = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => wagonService.updateWagon(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon updated successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update wagon');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteWagon = useMutation({
|
||||
mutationFn: (id: string) => wagonService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createWagon, updateWagon, deleteWagon };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const wagonTypesService = {
|
||||
async getWagonTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/wagon-types');
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const wagonService = {
|
||||
async getWagons() {
|
||||
const response = await api.get('/wagons');
|
||||
return response.data;
|
||||
},
|
||||
async getWagonById(id: string) {
|
||||
const response = await api.get(`/wagons/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
async createWagon(data: any) {
|
||||
const response = await api.post('/wagons', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateWagon(id: string, data: any) {
|
||||
const response = await api.patch(`/wagons/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteWagon(id: string) {
|
||||
await api.delete(`/wagons/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
import FleetRecordActions from "./FleetRecordActions";
|
||||
import { cardInitials, resolveFleetCardPresentation } from "./fleetCardMeta";
|
||||
import { formatFleetCell } from "./fleetFormat";
|
||||
import RuleEngineListFooter from "../ruleEngine/RuleEngineListFooter";
|
||||
|
||||
export interface FleetCardGridProps {
|
||||
config: FleetResourceConfig;
|
||||
rows: FleetRecord[];
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
config,
|
||||
rows,
|
||||
status,
|
||||
emptyMessage,
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: FleetCardGridProps) => {
|
||||
const presentation = resolveFleetCardPresentation(config);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Text size="sm" c="red" py="xl" ta="center">
|
||||
Failed to load data
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
||||
{rows.map((record) => {
|
||||
const title = String(
|
||||
(record as unknown as Record<string, unknown>)[presentation.titleKey] ?? config.entityLabel,
|
||||
);
|
||||
const code = presentation.codeKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.codeKey]
|
||||
: null;
|
||||
const subtitle = presentation.subtitleKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.subtitleKey]
|
||||
: null;
|
||||
const statusValue = presentation.statusKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.statusKey]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={String((record as { id: string }).id)}
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{cardInitials(title)}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{title || "—"}
|
||||
</Text>
|
||||
{subtitle != null && subtitle !== "" ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{String(subtitle)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{code != null && code !== "" ? (
|
||||
<Badge variant="light" color="blue" size="sm" radius="md">
|
||||
{String(code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
{config.columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.accessorKey !== presentation.titleKey &&
|
||||
col.accessorKey !== presentation.codeKey &&
|
||||
col.accessorKey !== presentation.statusKey,
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((col) => (
|
||||
<Group key={col.id} justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{col.header}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatFleetCell(
|
||||
(record as unknown as Record<string, unknown>)[col.accessorKey],
|
||||
col.format,
|
||||
col.accessorKey,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{statusValue != null ? (
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Status
|
||||
</Text>
|
||||
{formatFleetCell(statusValue, "statusBadge")}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<FleetRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
itemLabel={config.entityLabel.toLowerCase() + "s"}
|
||||
onPaginationChange={onPaginationChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetCardGrid;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
fields: FleetFormFieldDef[];
|
||||
initialRecord?: FleetRecord | null;
|
||||
emptyValues: Record<string, unknown>;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = (
|
||||
fields: FleetFormFieldDef[],
|
||||
emptyValues: Record<string, unknown>,
|
||||
record?: FleetRecord | null,
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = { ...emptyValues };
|
||||
if (!record) return values;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const raw = (record as unknown as Record<string, unknown>)[field.name];
|
||||
if (raw === null || raw === undefined) {
|
||||
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
|
||||
return;
|
||||
}
|
||||
values[field.name] = raw;
|
||||
});
|
||||
return values;
|
||||
};
|
||||
|
||||
const FleetFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
fields,
|
||||
initialRecord,
|
||||
emptyValues,
|
||||
isSubmitting,
|
||||
selectOptionsLoading,
|
||||
onSubmit,
|
||||
}: FleetFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, fields, emptyValues, initialRecord]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
|
||||
next[field.name] = `${field.label} is required`;
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
);
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const renderField = (field: FleetFormFieldDef) => {
|
||||
const value = values[field.name];
|
||||
const error = errors[field.name];
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
data={field.options ?? []}
|
||||
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<NumberInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={value === "" || value == null ? "" : Number(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: next === "" ? "" : next,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={<Text fw={600}>{title}</Text>}
|
||||
size="lg"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{shortFields.map(renderField)}
|
||||
</SimpleGrid>
|
||||
{longFields.map(renderField)}
|
||||
<Group justify="flex-end" gap="sm" mt="sm">
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MoreHorizontal, Pencil, Trash2, Truck } from "lucide-react";
|
||||
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetRecordActionsProps {
|
||||
record: FleetRecord;
|
||||
config: FleetResourceConfig;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
layout?: "row" | "compact";
|
||||
}
|
||||
|
||||
const FleetRecordActions = ({
|
||||
record,
|
||||
config,
|
||||
onEdit,
|
||||
onRemove,
|
||||
layout = "row",
|
||||
}: FleetRecordActionsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||
};
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={handleDetail}
|
||||
leftSection={<Truck size={14} />}
|
||||
>
|
||||
Manage wagons
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Pencil size={14} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Tooltip label="Manage wagons">
|
||||
<ActionIcon variant="subtle" color="green" size="md" radius="md" onClick={handleDetail}>
|
||||
<Truck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md" onClick={() => onEdit(record)}>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
|
||||
{removeLabel}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetRecordActions;
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||
|
||||
import type { FleetViewMode } from "./useFleetViewMode";
|
||||
|
||||
export interface FleetToolbarProps {
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
viewMode: FleetViewMode;
|
||||
onViewModeChange: (mode: FleetViewMode) => void;
|
||||
/** Optional filters rendered beside search (status, freight type, etc.) */
|
||||
filters?: ReactNode;
|
||||
}
|
||||
|
||||
const FleetToolbar = ({
|
||||
search = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
filters,
|
||||
}: FleetToolbarProps) => (
|
||||
<Box w="100%">
|
||||
<Group
|
||||
gap="md"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ flex: "1 1 280px", minWidth: 0 }}
|
||||
>
|
||||
{showSearch && onSearchChange ? (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
style={{ flex: "1 1 200px", minWidth: 180, maxWidth: 360 }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
) : null}
|
||||
{filters ? (
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{filters}
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={(value) => onViewModeChange(value as FleetViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Table2 size={14} />
|
||||
<span>Table</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<LayoutGrid size={14} />
|
||||
<span>Cards</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
styles={{
|
||||
root: { background: "var(--mantine-color-gray-1)" },
|
||||
}}
|
||||
/>
|
||||
{onAdd ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
fw={600}
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={onAdd}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default FleetToolbar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
|
||||
export interface FleetCardPresentation {
|
||||
titleKey: string;
|
||||
subtitleKey?: string;
|
||||
codeKey?: string;
|
||||
statusKey?: string;
|
||||
}
|
||||
|
||||
export const resolveFleetCardPresentation = (config: FleetResourceConfig): FleetCardPresentation => {
|
||||
const titleKey =
|
||||
config.cardTitleKey ??
|
||||
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "status")
|
||||
?.accessorKey ??
|
||||
"id";
|
||||
|
||||
const codeKey =
|
||||
config.cardCodeKey ?? config.columns.find((col) => col.format === "code")?.accessorKey;
|
||||
|
||||
const statusKey = config.columns.find((col) => col.format === "statusBadge")?.accessorKey;
|
||||
|
||||
const subtitleKey =
|
||||
config.cardSubtitleKey ??
|
||||
config.columns.find(
|
||||
(col) =>
|
||||
col.accessorKey !== titleKey &&
|
||||
col.accessorKey !== codeKey &&
|
||||
col.accessorKey !== statusKey,
|
||||
)?.accessorKey;
|
||||
|
||||
return { titleKey, subtitleKey, codeKey, statusKey };
|
||||
};
|
||||
|
||||
export const cardInitials = (title: string) => {
|
||||
const parts = title.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
|
||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
|
||||
export type FleetColumnFormat = ColumnFormat | "statusBadge";
|
||||
|
||||
const optionLabelMap = new Map<string, Map<string, string>>();
|
||||
|
||||
export const registerFleetOptionLabels = (
|
||||
fieldKey: string,
|
||||
options: { value: string; label: string }[],
|
||||
) => {
|
||||
optionLabelMap.set(fieldKey, new Map(options.map((o) => [o.value, o.label])));
|
||||
};
|
||||
|
||||
export const formatFleetCell = (
|
||||
value: unknown,
|
||||
format?: FleetColumnFormat,
|
||||
accessorKey?: string,
|
||||
): ReactNode => {
|
||||
if (format === "statusBadge") {
|
||||
const status = value == null || value === "" ? "—" : String(value);
|
||||
return (
|
||||
<Badge variant="light" color="gray" size="sm" radius="md">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (accessorKey && optionLabelMap.has(accessorKey)) {
|
||||
const label = optionLabelMap.get(accessorKey)?.get(String(value ?? ""));
|
||||
if (label) {
|
||||
return <Text size="sm">{label}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
|
||||
return raw === "cards" ? "cards" : "table";
|
||||
} catch {
|
||||
return "table";
|
||||
}
|
||||
};
|
||||
|
||||
export const useFleetViewMode = (slug: ViewModeSlug) => {
|
||||
const [viewMode, setViewModeState] = useState<FleetViewMode>(() => readStored(slug));
|
||||
|
||||
useEffect(() => {
|
||||
setViewModeState(readStored(slug));
|
||||
}, [slug]);
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: FleetViewMode) => {
|
||||
setViewModeState(mode);
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
return { viewMode, setViewMode };
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageMeta } from "./types";
|
||||
import { getFleetRouteMeta } from "@/pages/fleet/config/resources";
|
||||
import {
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_RESOURCES,
|
||||
@@ -35,6 +36,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||
meta: {
|
||||
title: "Train schedule",
|
||||
subtitle: "Assign bookings, auto-pin wagons, finalize and dispatch",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2",
|
||||
meta: {
|
||||
title: "Train Schedules v2",
|
||||
subtitle: "Operational train scheduling with full allocation workflow",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling",
|
||||
meta: {
|
||||
title: "Train Schedules",
|
||||
subtitle: "Create and manage container train schedules",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/routes",
|
||||
meta: {
|
||||
@@ -42,11 +64,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage route definitions built from freight yards",
|
||||
},
|
||||
},
|
||||
...getFleetRouteMeta(),
|
||||
{
|
||||
prefix: "/dashboard/locomotives",
|
||||
prefix: "/dashboard/trains/",
|
||||
meta: {
|
||||
title: "Locomotives",
|
||||
subtitle: "Manage locomotive master data and service status",
|
||||
title: "Train detail",
|
||||
subtitle: "Manage fleet consist and wagon assignments",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -91,6 +114,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage dropdown options used across the platform",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/configuration/train-scheduling-rules",
|
||||
meta: {
|
||||
title: "Train scheduling rules",
|
||||
subtitle: "Global limits for train length, weight, wagons, and 20ft container balance",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||
meta: {
|
||||
|
||||
@@ -10,9 +10,9 @@ const links = [
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Train scheduling",
|
||||
description: "Schedule container trains and eligible bookings",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
title: "Train scheduling v2",
|
||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
useEligibleBookings,
|
||||
useScheduleList,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useRoutes } from "@/hooks/useRoutes";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
FreightType,
|
||||
ReschedulePlan,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleListItem,
|
||||
TrainSchedulePreviewResponse,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
||||
import {
|
||||
autoFillPlacements,
|
||||
mergePlacementsWithSaved,
|
||||
placementsFromScheduleWagons,
|
||||
validateLocalPlacements,
|
||||
} from "./containerPlacement.util";
|
||||
import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util";
|
||||
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
||||
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
||||
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
|
||||
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import { WagonPlanGrid } from "./WagonPlanGrid";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const message = data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
const violations = data?.violations;
|
||||
if (Array.isArray(violations)) return violations.join(", ");
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const formatCountdown = (expiresAt?: string | null) => {
|
||||
if (!expiresAt) return null;
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
if (diff <= 0) return "Hold expired";
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.floor((diff % 3600000) / 60000);
|
||||
return `${hours}h ${mins}m remaining`;
|
||||
};
|
||||
|
||||
export function AllocateBookingWizard({
|
||||
booking,
|
||||
opened,
|
||||
onClose,
|
||||
initialBookingIds,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
initialBookingIds?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const bookingFreightType = booking.freightType as FreightType;
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [scheduleMode, setScheduleMode] = useState<"existing" | "new">("existing");
|
||||
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const scheduleDate = booking.scheduledDate;
|
||||
const [locomotiveId, setLocomotiveId] = useState("");
|
||||
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [assignedSchedule, setAssignedSchedule] = useState<TrainScheduleDetail | null>(null);
|
||||
const [reschedulePlan, setReschedulePlan] = useState<ReschedulePlan | null>(null);
|
||||
const [confirmPreempt, setConfirmPreempt] = useState(false);
|
||||
const [allocationComplete, setAllocationComplete] = useState(false);
|
||||
|
||||
const originId = booking.originYard?.id;
|
||||
const destinationId = booking.destinationYard?.id;
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() => ({
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
}),
|
||||
[originId, destinationId],
|
||||
);
|
||||
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
const matchingSchedules = useMemo(
|
||||
() =>
|
||||
(schedulesQuery.data ?? []).filter(
|
||||
(s: TrainScheduleListItem) =>
|
||||
s.status === "DRAFT" &&
|
||||
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
|
||||
),
|
||||
[schedulesQuery.data, bookingFreightType],
|
||||
);
|
||||
|
||||
const allBookingIds = useMemo(
|
||||
() => [booking.id, ...extraBookingIds.filter((id) => id !== booking.id)],
|
||||
[booking.id, extraBookingIds],
|
||||
);
|
||||
|
||||
const containerUnits = previewResult?.containerUnits ?? [];
|
||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||
const hasContainerStep = useMemo(
|
||||
() =>
|
||||
shouldShowContainerPlacementStep({
|
||||
containerUnitCount: containerUnits.length,
|
||||
scheduleFreightType: booking.freightType,
|
||||
bookingFreightTypes: [
|
||||
booking.freightType,
|
||||
...(eligibleQuery.data?.items ?? [])
|
||||
.filter((item) => allBookingIds.includes(item.id))
|
||||
.map((item) => item.freightType),
|
||||
],
|
||||
}),
|
||||
[
|
||||
allBookingIds,
|
||||
booking.freightType,
|
||||
containerUnits.length,
|
||||
eligibleQuery.data?.items,
|
||||
],
|
||||
);
|
||||
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setActiveStep(0);
|
||||
setPreviewResult(null);
|
||||
setAssignedSchedule(null);
|
||||
setExtraBookingIds([]);
|
||||
setContainerPlacements([]);
|
||||
setReschedulePlan(null);
|
||||
setConfirmPreempt(false);
|
||||
setAllocationComplete(false);
|
||||
return;
|
||||
}
|
||||
if (initialBookingIds?.length) {
|
||||
setExtraBookingIds(initialBookingIds.filter((id) => id !== booking.id));
|
||||
}
|
||||
}, [opened, booking.id, initialBookingIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (matchingSchedules.length && !selectedScheduleId) {
|
||||
setSelectedScheduleId(matchingSchedules[0].id);
|
||||
}
|
||||
}, [matchingSchedules, selectedScheduleId]);
|
||||
|
||||
const savedPlacementsFromSchedule = useMemo(
|
||||
() =>
|
||||
assignedSchedule?.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(assignedSchedule.trainSet.wagons)
|
||||
: [],
|
||||
[assignedSchedule?.trainSet?.wagons],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerUnits.length || !containerSlots.length) return;
|
||||
|
||||
setContainerPlacements((current) => {
|
||||
if (current.length && current.some((p) => p.containerNumber?.trim())) {
|
||||
return current;
|
||||
}
|
||||
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
|
||||
if (savedPlacementsFromSchedule.length) {
|
||||
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
|
||||
}
|
||||
if (current.length) return current;
|
||||
return autoFilled;
|
||||
});
|
||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const ensureSchedule = async (): Promise<string> => {
|
||||
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
throw new Error("Select route, date, and locomotive");
|
||||
}
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveId },
|
||||
});
|
||||
setSelectedScheduleId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!originId || !destinationId) {
|
||||
toast({ title: "Booking missing origin or destination", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const targetScheduleId =
|
||||
scheduleMode === "existing" ? (selectedScheduleId ?? undefined) : undefined;
|
||||
const result = await preview.mutateAsync({
|
||||
payload: {
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
targetScheduleId,
|
||||
},
|
||||
});
|
||||
setPreviewResult(result);
|
||||
if (booking.isGovernment && targetScheduleId) {
|
||||
const plan = (await trainSchedulingService.previewReschedule(targetScheduleId, {
|
||||
incomingBookingIds: allBookingIds,
|
||||
trigger: "GOVERNMENT_PREEMPT",
|
||||
})) as ReschedulePlan;
|
||||
setReschedulePlan(plan);
|
||||
} else {
|
||||
setReschedulePlan(null);
|
||||
}
|
||||
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
|
||||
const autoFilled = autoFillPlacements(
|
||||
result.containerUnits,
|
||||
result.containerSlotSequenceNos,
|
||||
);
|
||||
setContainerPlacements(autoFilled);
|
||||
}
|
||||
setActiveStep(1);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Preview failed",
|
||||
description: parseError(err, "Could not preview"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (hasContainerStep) {
|
||||
const issues = validateLocalPlacements(containerUnits, containerPlacements);
|
||||
if (issues.length) {
|
||||
toast({
|
||||
title: "Complete container assignments",
|
||||
description: issues.join(", "),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (reschedulePlan?.displaced.length && !confirmPreempt) {
|
||||
toast({
|
||||
title: "Confirm displacement",
|
||||
description: "Acknowledge displaced bookings before assigning",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduleId = await ensureSchedule();
|
||||
let result: TrainScheduleDetail;
|
||||
if (reschedulePlan?.displaced.length) {
|
||||
const executed = await trainSchedulingService.executeReschedule(scheduleId, {
|
||||
incomingBookingIds: allBookingIds,
|
||||
trigger: "GOVERNMENT_PREEMPT",
|
||||
finalBookingIds: reschedulePlan.finalBookingIds,
|
||||
displacedBookingIds: reschedulePlan.displaced.map((b) => b.id),
|
||||
});
|
||||
result = (executed as { schedule: TrainScheduleDetail }).schedule;
|
||||
} else {
|
||||
result = await assign.mutateAsync({
|
||||
id: scheduleId,
|
||||
freightType: previewFreightType,
|
||||
payload: {
|
||||
bookingIds: allBookingIds,
|
||||
forceAssign,
|
||||
containerPlacements: hasContainerStep ? containerPlacements : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
setAssignedSchedule(result);
|
||||
const saved = result.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(result.trainSet.wagons)
|
||||
: [];
|
||||
if (saved.length) {
|
||||
setContainerPlacements(saved);
|
||||
}
|
||||
setActiveStep(finalizeStep);
|
||||
toast({ title: "Bookings assigned — wagons auto-pinned" });
|
||||
if (result.deferredBookings?.length) {
|
||||
toast({
|
||||
title: "Partial assignment",
|
||||
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Assign failed",
|
||||
description: parseError(err, "Could not assign"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
const scheduleId = assignedSchedule?.id ?? selectedScheduleId;
|
||||
if (!scheduleId) return;
|
||||
try {
|
||||
const finalized = await finalize.mutateAsync(scheduleId);
|
||||
setAssignedSchedule(finalized);
|
||||
setAllocationComplete(true);
|
||||
toast({ title: "Schedule finalized — booking allocated" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Finalize failed",
|
||||
description: parseError(err, "Could not finalize schedule"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const stepDescription =
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart";
|
||||
|
||||
const stepIcon =
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Allocate booking — {booking.reference}</Text>}
|
||||
size="90%"
|
||||
radius="xl"
|
||||
centered
|
||||
styles={{ content: { maxWidth: 1200 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Allocation workflow"
|
||||
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"} → ${booking.destinationYard?.name ?? "Destination"}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={stepDescription}
|
||||
stepIcon={stepIcon}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{booking.reference}</Text>
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.freightType} · {booking.cargoTotalWeightVgm}T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{booking.originYard?.name ?? "Origin"} →{" "}
|
||||
{booking.destinationYard?.name ?? "Destination"}
|
||||
</Text>
|
||||
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{holdCountdown ? (
|
||||
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: l.code,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button loading={preview.isPending} onClick={handlePreview}>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
<PreviewSummary summary={previewResult?.summary} />
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={previewResult?.wagonPlan ?? []}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
{allocationComplete ? (
|
||||
<Paper p="lg" radius="xl" withBorder bg="teal.0">
|
||||
<Stack gap="md" align="center">
|
||||
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED and completes the booking
|
||||
allocation.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, Container } from "lucide-react";
|
||||
|
||||
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
|
||||
|
||||
import { autoFillPlacements, unitKey, validateLocalPlacements } from "./containerPlacement.util";
|
||||
|
||||
export function ContainerPlacementGrid({
|
||||
units,
|
||||
containerSlots,
|
||||
placements,
|
||||
onChange,
|
||||
}: {
|
||||
units: ContainerUnitRow[];
|
||||
containerSlots: number[];
|
||||
placements: ContainerPlacement[];
|
||||
onChange: (placements: ContainerPlacement[]) => void;
|
||||
}) {
|
||||
const placementMap = useMemo(() => {
|
||||
const map = new Map<string, ContainerPlacement>();
|
||||
for (const placement of placements) {
|
||||
map.set(unitKey(placement.bookingContainerId, placement.unitIndex), placement);
|
||||
}
|
||||
return map;
|
||||
}, [placements]);
|
||||
|
||||
const issues = useMemo(() => validateLocalPlacements(units, placements), [units, placements]);
|
||||
|
||||
const completedCount = useMemo(
|
||||
() =>
|
||||
units.filter((unit) => {
|
||||
const placement = placementMap.get(unitKey(unit.bookingContainerId, unit.unitIndex));
|
||||
return placement?.sequenceNo && placement.containerNumber?.trim();
|
||||
}).length,
|
||||
[units, placementMap],
|
||||
);
|
||||
|
||||
const slotOptions = containerSlots.map((seq) => ({
|
||||
value: String(seq),
|
||||
label: `Wagon #${seq}`,
|
||||
}));
|
||||
|
||||
const updatePlacement = (unit: ContainerUnitRow, patch: Partial<ContainerPlacement>) => {
|
||||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||||
const existing = placementMap.get(key);
|
||||
const next: ContainerPlacement = {
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo: existing?.sequenceNo ?? containerSlots[0] ?? 1,
|
||||
containerNumber: existing?.containerNumber,
|
||||
sealNumber: existing?.sealNumber,
|
||||
...patch,
|
||||
};
|
||||
onChange([
|
||||
...placements.filter(
|
||||
(p) => !(p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex),
|
||||
),
|
||||
next,
|
||||
]);
|
||||
};
|
||||
|
||||
if (!units.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No container units in this selection.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = units.length ? Math.round((completedCount / units.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Container size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Container assignment
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Map each booking unit to a wagon slot and enter the container number. One wagon fits
|
||||
either 1×40ft or 2×20ft containers.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() => onChange(autoFillPlacements(units, containerSlots))}
|
||||
>
|
||||
Auto-fill slots
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{completedCount} of {units.length} units complete
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={issues.length ? "yellow" : "teal"}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{issues.length ? (
|
||||
<Stack gap={6}>
|
||||
{issues.map((issue) => (
|
||||
<Badge key={issue} color="red" variant="light" size="sm" w="fit-content">
|
||||
{issue}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Badge
|
||||
color="teal"
|
||||
variant="light"
|
||||
size="sm"
|
||||
w="fit-content"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
>
|
||||
All units mapped
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{units.map((unit) => {
|
||||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||||
const placement = placementMap.get(key);
|
||||
const isComplete = placement?.sequenceNo && placement.containerNumber?.trim();
|
||||
|
||||
return (
|
||||
<Card key={key} radius="xl" padding="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{unit.bookingReference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="sm" variant="light" color={isComplete ? "teal" : "gray"}>
|
||||
{isComplete ? "Ready" : "Pending"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Wagon slot"
|
||||
size="sm"
|
||||
data={slotOptions}
|
||||
value={placement?.sequenceNo ? String(placement.sequenceNo) : null}
|
||||
onChange={(value) =>
|
||||
updatePlacement(unit, { sequenceNo: Number(value ?? containerSlots[0]) })
|
||||
}
|
||||
placeholder="Select wagon"
|
||||
searchable
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
size="sm"
|
||||
placeholder="e.g. MSCU1234567"
|
||||
value={placement?.containerNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
updatePlacement(unit, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||
import { groupBookingsByThreeHourWindow } from "@/utils/groupBookingsByThreeHourWindow";
|
||||
|
||||
function EligibleBookingRow({
|
||||
booking,
|
||||
freightType,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
booking: EligibleContainerBooking;
|
||||
freightType?: FreightType;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const resolvedFreightType = booking.freightType ?? freightType;
|
||||
const isBulk = resolvedFreightType === "BULK";
|
||||
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 10,
|
||||
background: selected ? "var(--mantine-color-teal-0)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} onChange={onToggle} mt={4} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{resolvedFreightType ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
{resolvedFreightType}
|
||||
</Badge>
|
||||
) : null}
|
||||
{booking.schedulingStatus ? (
|
||||
<Badge variant="light" size="xs">
|
||||
{booking.schedulingStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.customer}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="xs">{booking.origin}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs">{booking.destination}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<BookingPriorityBadge score={booking.priorityScore ?? 0} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{isBulk
|
||||
? `${booking.weightTons}T`
|
||||
: `${booking.quantity} × ${booking.containerType}`}
|
||||
</Text>
|
||||
{booking.preferredDepartureDate ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(booking.preferredDepartureDate).toLocaleString("en-GB", {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}{" "}
|
||||
UTC
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function EligibleBookingsPanel({
|
||||
items,
|
||||
isLoading,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
assignedIds = [],
|
||||
freightType,
|
||||
}: {
|
||||
items: EligibleContainerBooking[];
|
||||
isLoading?: boolean;
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
assignedIds?: string[];
|
||||
freightType?: FreightType;
|
||||
}) {
|
||||
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
|
||||
|
||||
const availableItems = useMemo(
|
||||
() => items.filter((b) => !assignedSet.has(b.id)),
|
||||
[items, assignedSet],
|
||||
);
|
||||
|
||||
const buckets = useMemo(
|
||||
() => groupBookingsByThreeHourWindow(availableItems),
|
||||
[availableItems],
|
||||
);
|
||||
|
||||
const selectableIds = useMemo(() => {
|
||||
return [...assignedIds, ...availableItems.map((b) => b.id)];
|
||||
}, [availableItems, assignedIds]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter((x) => x !== id));
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketIds: string[], select: boolean) => {
|
||||
if (select) {
|
||||
const merged = new Set([...selectedIds, ...bucketIds]);
|
||||
onSelectionChange([...merged]);
|
||||
} else {
|
||||
onSelectionChange(selectedIds.filter((id) => !bucketIds.includes(id)));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading eligible bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items.length && !assignedIds.length) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No eligible bookings for this corridor
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
Eligible bookings ({availableItems.length})
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() => onSelectionChange(selectableIds)}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<Button variant="subtle" size="compact-sm" onClick={() => onSelectionChange(assignedIds)}>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{buckets.length > 0 ? (
|
||||
<Accordion defaultValue={buckets[0]?.key} variant="separated" radius="lg">
|
||||
{buckets.map((bucket) => {
|
||||
const bucketIds = bucket.bookings.map((b) => b.id);
|
||||
const selectedInBucket = bucketIds.filter((id) => selectedIds.includes(id));
|
||||
const allSelected = bucketIds.length > 0 && selectedInBucket.length === bucketIds.length;
|
||||
|
||||
return (
|
||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{bucket.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bucket.bookings.length} booking
|
||||
{bucket.bookings.length === 1 ? "" : "s"} · priority sorted
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Badge variant="light" color="teal">
|
||||
{selectedInBucket.length} selected
|
||||
</Badge>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBucket(bucketIds, !allSelected);
|
||||
}}
|
||||
>
|
||||
{allSelected ? "Deselect bucket" : "Select bucket"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{bucket.bookings.map((booking) => (
|
||||
<EligibleBookingRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
freightType={freightType}
|
||||
selected={selectedIds.includes(booking.id)}
|
||||
onToggle={() => toggle(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No additional eligible bookings in this corridor.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, Train } from "lucide-react";
|
||||
|
||||
import type { DeferredBookingRow, FleetAvailabilityRow } from "@/types/trainScheduling";
|
||||
|
||||
export function FleetAvailabilitySummary({
|
||||
fleetAvailability = [],
|
||||
deferredBookings = [],
|
||||
}: {
|
||||
fleetAvailability?: FleetAvailabilityRow[];
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
}) {
|
||||
if (!fleetAvailability.length && !deferredBookings.length) return null;
|
||||
|
||||
const totalNeeded = fleetAvailability.reduce((sum, row) => sum + row.needed, 0);
|
||||
const totalAvailable = fleetAvailability.reduce((sum, row) => sum + row.available, 0);
|
||||
const totalShortfall = fleetAvailability.reduce((sum, row) => sum + row.shortfall, 0);
|
||||
const fillRate =
|
||||
totalNeeded > 0 ? Math.round((Math.min(totalAvailable, totalNeeded) / totalNeeded) * 100) : 100;
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
<Train size={18} />
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
Fleet wagon availability
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Plan is capped to available physical wagons by type
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "teal"}>
|
||||
{fillRate}% fleet coverage
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{totalNeeded > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.min(totalAvailable, totalNeeded)} of {totalNeeded} wagon slots can be filled
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={fillRate}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={totalShortfall > 0 ? "yellow" : "teal"}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{fleetAvailability.length > 0 ? (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon type</Table.Th>
|
||||
<Table.Th>Needed</Table.Th>
|
||||
<Table.Th>Available</Table.Th>
|
||||
<Table.Th>Shortfall</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{fleetAvailability.map((row) => (
|
||||
<Table.Tr key={row.wagonTypeId}>
|
||||
<Table.Td>{row.wagonTypeCode}</Table.Td>
|
||||
<Table.Td>{row.needed}</Table.Td>
|
||||
<Table.Td>{row.available}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.shortfall > 0 ? (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{row.shortfall}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="teal">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : null}
|
||||
|
||||
{totalShortfall > 0 || deferredBookings.length > 0 ? (
|
||||
<Alert color="yellow" variant="light" radius="lg" icon={<AlertTriangle size={16} />}>
|
||||
<Text size="sm">
|
||||
Train will depart with available wagons only.
|
||||
{deferredBookings.length
|
||||
? ` ${deferredBookings.length} booking(s) will wait for the next train.`
|
||||
: ""}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{deferredBookings.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
{deferredBookings.map((booking) => (
|
||||
<Paper key={booking.id} p="sm" radius="lg" withBorder bg="gray.0">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.reason}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Next train
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, Link2, Wand2 } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
|
||||
|
||||
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
|
||||
|
||||
export function PinWagonsForm({
|
||||
schedule,
|
||||
availableWagons,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
autoFillOnMount = true,
|
||||
}: {
|
||||
schedule: TrainScheduleDetail;
|
||||
availableWagons: Wagon[];
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (assignments: PinWagonAssignment[]) => void;
|
||||
autoFillOnMount?: boolean;
|
||||
}) {
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
|
||||
const wagonOptionsByType = useMemo(() => {
|
||||
const map = new Map<string, Array<{ value: string; label: string }>>();
|
||||
for (const wagon of availableWagons) {
|
||||
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
|
||||
if (
|
||||
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
|
||||
allowPinned: isPinnedOnSlot,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (wagon.status !== Freight.WagonStatus.Available && !isPinnedOnSlot) {
|
||||
continue;
|
||||
}
|
||||
const typeId = wagon.wagonTypeId;
|
||||
const list = map.get(typeId) ?? [];
|
||||
list.push({ value: wagon.id, label: wagon.wagonNumber });
|
||||
map.set(typeId, list);
|
||||
}
|
||||
return map;
|
||||
}, [availableWagons, schedule.direction, slots]);
|
||||
|
||||
const runAutoFill = useCallback(
|
||||
(preserveManual = false) => {
|
||||
const existing = preserveManual ? assignments : {};
|
||||
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType, existing));
|
||||
},
|
||||
[assignments, slots, wagonOptionsByType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFillOnMount || !slots.length) return;
|
||||
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType));
|
||||
}, [schedule.id, slots, wagonOptionsByType, autoFillOnMount]);
|
||||
|
||||
const fillStats = useMemo(
|
||||
() => countFilledSlots(slots, assignments),
|
||||
[slots, assignments],
|
||||
);
|
||||
|
||||
const progress =
|
||||
fillStats.total > 0 ? Math.round((fillStats.filled / fillStats.total) * 100) : 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload: PinWagonAssignment[] = Object.entries(assignments)
|
||||
.filter(([, wagonId]) => Boolean(wagonId))
|
||||
.map(([trainSetWagonId, physicalWagonId]) => ({ trainSetWagonId, physicalWagonId }));
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
if (!slots.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Assign bookings first to create wagon slots.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Link2 size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Pin physical wagons
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Match each train slot to a fleet wagon. Slots are auto-filled when possible.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<Wand2 size={14} />}
|
||||
onClick={() => runAutoFill(false)}
|
||||
>
|
||||
Auto-fill all slots
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fillStats.filled} of {fillStats.total} slots filled
|
||||
</Text>
|
||||
<Badge variant="light" color={progress === 100 ? "teal" : "yellow"}>
|
||||
{progress}%
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={progress} size="sm" radius="xl" color={progress === 100 ? "teal" : "yellow"} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{fillStats.unfilledSlotNumbers.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Some slots could not be auto-filled"
|
||||
>
|
||||
<Text size="sm">
|
||||
No matching fleet wagon for slot
|
||||
{fillStats.unfilledSlotNumbers.length === 1 ? "" : "s"} #
|
||||
{fillStats.unfilledSlotNumbers.join(", #")}. Select manually or add wagons to the fleet.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{slots.map((slot) => {
|
||||
const typeId = slot.wagonType?.id ?? "";
|
||||
const options =
|
||||
wagonOptionsByType.get(typeId) ??
|
||||
availableWagons.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.wagonNumber,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Paper key={slot.id} p="md" radius="lg" withBorder>
|
||||
<Group align="flex-end" wrap="nowrap" gap="md">
|
||||
<Stack gap={2} style={{ minWidth: 90 }}>
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
|
||||
<Text size="xs" fw={700}>
|
||||
{slot.sequenceNo}
|
||||
</Text>
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
Slot #{slot.sequenceNo}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{slot.wagonType?.code ?? "—"} · {slot.capacityTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Select physical wagon"
|
||||
data={options}
|
||||
value={assignments[slot.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAssignments((current) => ({
|
||||
...current,
|
||||
[slot.id]: value ?? "",
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button color="teal" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Pin wagons
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
|
||||
export function RescheduleTrainDialog({
|
||||
scheduleId,
|
||||
currentBookingIds,
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
currentBookingIds: string[];
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const [newDepartureDate, setNewDepartureDate] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newDepartureDate) {
|
||||
toast.error("Select a new departure date");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await trainSchedulingService.maintenanceReschedule(scheduleId, {
|
||||
incomingBookingIds: currentBookingIds,
|
||||
newDepartureDate: new Date(newDepartureDate).toISOString(),
|
||||
reason,
|
||||
});
|
||||
toast.success("Train rescheduled for maintenance");
|
||||
onComplete?.();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Reschedule failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Reschedule train (maintenance)" radius="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updates departure and rebalances bookings on this train. Displaced bookings return to
|
||||
the operations queue when capacity is insufficient.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="New departure"
|
||||
type="datetime-local"
|
||||
value={newDepartureDate}
|
||||
onChange={(e) => setNewDepartureDate(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="e.g. Locomotive maintenance"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={loading} onClick={handleSubmit}>
|
||||
Reschedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ArrowRight, Package, Train } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||
|
||||
import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
|
||||
|
||||
export type AssignedBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
weightTons?: number;
|
||||
};
|
||||
|
||||
export function ScheduleBookingsStep({
|
||||
assignedBookings,
|
||||
eligibleItems,
|
||||
eligibleLoading,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
assignedIds,
|
||||
freightType,
|
||||
canRemove,
|
||||
onRemove,
|
||||
}: {
|
||||
assignedBookings: AssignedBookingRow[];
|
||||
eligibleItems: EligibleContainerBooking[];
|
||||
eligibleLoading?: boolean;
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
assignedIds?: string[];
|
||||
freightType?: FreightType;
|
||||
canRemove?: boolean;
|
||||
onRemove?: (bookingId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Tabs defaultValue={assignedBookings.length ? "on-train" : "add"} radius="lg" variant="pills">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab
|
||||
value="on-train"
|
||||
leftSection={<Train size={14} />}
|
||||
rightSection={
|
||||
assignedBookings.length ? (
|
||||
<Badge size="xs" variant="light" color="teal" circle>
|
||||
{assignedBookings.length}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
On this train
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="add" leftSection={<Package size={14} />}>
|
||||
Add bookings
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="on-train">
|
||||
{assignedBookings.length ? (
|
||||
<Stack gap="sm">
|
||||
{assignedBookings.map((booking) => (
|
||||
<Group
|
||||
key={booking.id}
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-teal-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.weightTons != null ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
{booking.weightTons}T
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Assigned to this consist
|
||||
</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs" c="teal">
|
||||
Ready for wagon plan
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
{canRemove && onRemove ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-xs"
|
||||
onClick={() => onRemove(booking.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No bookings on this train yet. Use the Add bookings tab to select eligible cargo.
|
||||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="add">
|
||||
<EligibleBookingsPanel
|
||||
items={eligibleItems}
|
||||
isLoading={eligibleLoading}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={onSelectionChange}
|
||||
assignedIds={assignedIds}
|
||||
freightType={freightType}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
SCHEDULED: "blue",
|
||||
DISPATCHED: "green",
|
||||
ARRIVED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
export function ScheduleStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant="light" color={STATUS_COLORS[status] ?? "gray"} size="sm">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreightTypeBadge({ freightType }: { freightType?: string | null }) {
|
||||
if (!freightType) return <Badge variant="light" color="gray" size="sm">—</Badge>;
|
||||
const color =
|
||||
freightType === "BULK" ? "orange" : freightType === "MIXED" ? "grape" : "cyan";
|
||||
return (
|
||||
<Badge variant="light" color={color} size="sm">
|
||||
{freightType}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchedulingStatusBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return null;
|
||||
const colors: Record<string, string> = {
|
||||
NOT_SCHEDULED: "gray",
|
||||
HOLDING: "yellow",
|
||||
ELIGIBLE: "blue",
|
||||
SCHEDULED: "indigo",
|
||||
DISPATCHED: "green",
|
||||
};
|
||||
return (
|
||||
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">
|
||||
{status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Alert, List, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { AlertTriangle, XCircle } from "lucide-react";
|
||||
|
||||
export function ScheduleWarningsAlert({
|
||||
violations = [],
|
||||
warnings = [],
|
||||
}: {
|
||||
violations?: string[];
|
||||
warnings?: string[];
|
||||
}) {
|
||||
if (!violations.length && !warnings.length) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{violations.length > 0 ? (
|
||||
<Alert color="red" radius="xl" icon={<XCircle size={16} />} title="Violations">
|
||||
<List size="sm" spacing={4}>
|
||||
{violations.map((v) => (
|
||||
<List.Item key={v}>{v}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
{warnings.length > 0 ? (
|
||||
<Alert color="yellow" radius="xl" icon={<AlertTriangle size={16} />} title="Warnings">
|
||||
<List size="sm" spacing={4}>
|
||||
{warnings.map((w) => (
|
||||
<List.Item key={w}>{w}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewSummary({
|
||||
summary,
|
||||
}: {
|
||||
summary?: {
|
||||
totalBookings: number;
|
||||
totalWeightTons: number;
|
||||
wagonType: string;
|
||||
wagonsNeeded: number;
|
||||
totalLengthMeters: number;
|
||||
};
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
const stats = [
|
||||
{ label: "Bookings", value: String(summary.totalBookings) },
|
||||
{ label: "Wagons", value: String(summary.wagonsNeeded) },
|
||||
{ label: "Wagon type", value: summary.wagonType },
|
||||
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
|
||||
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
|
||||
];
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder bg="teal.0">
|
||||
<Text size="sm" fw={600} mb="sm">
|
||||
Plan summary
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: 5 }} spacing="sm">
|
||||
{stats.map((stat) => (
|
||||
<Stack key={stat.label} gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Badge, Group, Paper, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Container,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
const stepIcons: Record<string, LucideIcon> = {
|
||||
package: Package,
|
||||
layout: LayoutGrid,
|
||||
container: Container,
|
||||
link: Link2,
|
||||
check: CheckCircle2,
|
||||
};
|
||||
|
||||
export function SchedulingWorkflowHeader({
|
||||
title,
|
||||
subtitle,
|
||||
activeStep,
|
||||
totalSteps,
|
||||
stepLabel,
|
||||
stepDescription,
|
||||
stepIcon = "package",
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
activeStep: number;
|
||||
totalSteps: number;
|
||||
stepLabel: string;
|
||||
stepDescription?: string;
|
||||
stepIcon?: keyof typeof stepIcons;
|
||||
}) {
|
||||
const Icon = stepIcons[stepIcon] ?? Package;
|
||||
const progress = totalSteps > 0 ? Math.round(((activeStep + 1) / totalSteps) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="xl"
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-white) 0%, var(--mantine-color-gray-0) 100%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="flex-start">
|
||||
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Text fw={700} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color="teal">
|
||||
Step {activeStep + 1} of {totalSteps}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{stepLabel}
|
||||
{stepDescription ? (
|
||||
<Text span c="dimmed" fw={400}>
|
||||
{" "}
|
||||
· {stepDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={progress} size="sm" radius="xl" color="teal" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Box, Package } from "lucide-react";
|
||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||
|
||||
type WagonSlot = WagonPlanRow | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
slotLoadType?: string;
|
||||
wagonType?: { code: string } | null;
|
||||
wagonTypeCode?: string;
|
||||
physicalWagonNumber?: string | null;
|
||||
allocations?: TrainScheduleWagonAllocation[] | Array<{
|
||||
id?: string;
|
||||
bookingId: string;
|
||||
bookingReference?: string | null;
|
||||
allocatedWeightTons: number;
|
||||
loadType?: string | null;
|
||||
containerItems?: Array<{ containerNumber: string | null; grossWeightTons?: number | null }>;
|
||||
bulkLoad?: { weightTons: number; cargoDescription: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
|
||||
const normalized = loadType?.toUpperCase() ?? "";
|
||||
if (normalized.includes("BULK")) return "orange";
|
||||
if (normalized.includes("CONTAINER")) return "cyan";
|
||||
return freightType === "BULK" ? "orange" : "cyan";
|
||||
}
|
||||
|
||||
function slotLabel(slot: WagonSlot, freightType?: string | null) {
|
||||
if ("slotLoadType" in slot && slot.slotLoadType) return slot.slotLoadType;
|
||||
const fromAlloc = slot.allocations?.[0]?.loadType?.toString().toUpperCase();
|
||||
if (fromAlloc) return fromAlloc;
|
||||
if (freightType === "MIXED") return "MIXED";
|
||||
return freightType ?? "SLOT";
|
||||
}
|
||||
|
||||
function wagonTypeLabel(slot: WagonSlot) {
|
||||
if ("wagonType" in slot && slot.wagonType?.code) return slot.wagonType.code;
|
||||
if ("wagonTypeCode" in slot && slot.wagonTypeCode) return slot.wagonTypeCode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WagonPlanGrid({
|
||||
wagonPlan,
|
||||
freightType,
|
||||
}: {
|
||||
wagonPlan: WagonSlot[];
|
||||
freightType?: string | null;
|
||||
}) {
|
||||
if (!wagonPlan?.length) {
|
||||
return (
|
||||
<Card radius="lg" padding="xl" withBorder bg="gray.0">
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size="lg" radius="xl" variant="light" color="gray">
|
||||
<Package size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500}>
|
||||
No wagon plan yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={360}>
|
||||
Select bookings and run <strong>Preview plan</strong> to generate wagon slots and
|
||||
allocations.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
|
||||
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
|
||||
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
|
||||
|
||||
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
<strong>{wagonPlan.length}</strong> wagons · <strong>{usedSlots}</strong> in use
|
||||
</Text>
|
||||
{isBulk ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
|
||||
{wagonPlan.map((wagon) => {
|
||||
const seq = wagon.sequenceNo;
|
||||
const capacity = wagon.capacityTons;
|
||||
const assigned = wagon.assignedWeightTons;
|
||||
const allocations = wagon.allocations ?? [];
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const label = slotLabel(wagon, freightType);
|
||||
const typeCode = wagonTypeLabel(wagon);
|
||||
|
||||
return (
|
||||
<Card key={seq} radius="lg" padding="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="md" radius="md" variant="light" color={loadTypeColor(label, freightType)}>
|
||||
<Box size={16} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">
|
||||
Wagon #{seq}
|
||||
</Text>
|
||||
{typeCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{typeCode}
|
||||
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" size="sm" color={loadTypeColor(label, freightType)}>
|
||||
{label}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{label === "BULK" ? (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Capacity
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{assigned} / {capacity}T
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={utilization}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6}>
|
||||
{allocations.length ? (
|
||||
allocations.map((alloc, index) => (
|
||||
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
|
||||
<Stack gap={2}>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{alloc.bookingReference ?? alloc.bookingId}
|
||||
</Text>
|
||||
{label === "BULK" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{alloc.allocatedWeightTons}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{"containerItems" in alloc && alloc.containerItems?.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{alloc.containerItems.length} container{alloc.containerItems.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
{"bulkLoad" in alloc && alloc.bulkLoad ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
Bulk · {alloc.bulkLoad.weightTons}T
|
||||
{alloc.bulkLoad.cargoDescription
|
||||
? ` — ${alloc.bulkLoad.cargoDescription}`
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Empty slot
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { autoFillPlacements, unitKey, validateLocalPlacements } from './containerPlacement.util';
|
||||
import type { ContainerUnitRow } from '@/types/trainScheduling';
|
||||
|
||||
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
|
||||
const units: ContainerUnitRow[] = [];
|
||||
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
|
||||
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
units.push({
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BKG-001',
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: i,
|
||||
containerTypeId: 'ct-1',
|
||||
containerTypeCode: containerType,
|
||||
label: `${containerType} ${i + 1}/${quantity}`,
|
||||
grossWeightTons: 25,
|
||||
sizeFt,
|
||||
wagonsPerUnit,
|
||||
containersPerWagon,
|
||||
teuSlots: sizeFt >= 40 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
describe('containerPlacement.util', () => {
|
||||
describe('unitKey', () => {
|
||||
it('creates unique keys for units', () => {
|
||||
expect(unitKey('bc-1', 0)).toBe('bc-1:0');
|
||||
expect(unitKey('bc-1', 1)).toBe('bc-1:1');
|
||||
expect(unitKey('bc-2', 0)).toBe('bc-2:0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFillPlacements', () => {
|
||||
it('returns empty array when no units or slots', () => {
|
||||
expect(autoFillPlacements([], [1, 2, 3])).toEqual([]);
|
||||
expect(autoFillPlacements(makeUnits('20GP', 20, 1), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('places 2×20ft containers in 1 wagon slot', () => {
|
||||
const units = makeUnits('20GP', 20, 2);
|
||||
const slots = [1, 2, 3];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(2);
|
||||
// Both 20ft containers should be in slot 1
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(1);
|
||||
});
|
||||
|
||||
it('places 6×20ft containers in 3 wagon slots (2 per wagon)', () => {
|
||||
const units = makeUnits('20GP', 20, 6);
|
||||
const slots = [1, 2, 3, 4, 5];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(6);
|
||||
// Units 0,1 -> Slot 1
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(1);
|
||||
// Units 2,3 -> Slot 2
|
||||
expect(placements[2]?.sequenceNo).toBe(2);
|
||||
expect(placements[3]?.sequenceNo).toBe(2);
|
||||
// Units 4,5 -> Slot 3
|
||||
expect(placements[4]?.sequenceNo).toBe(3);
|
||||
expect(placements[5]?.sequenceNo).toBe(3);
|
||||
});
|
||||
|
||||
it('places 1×40ft container in 1 wagon slot', () => {
|
||||
const units = makeUnits('40GP', 40, 1);
|
||||
const slots = [1, 2, 3];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
});
|
||||
|
||||
it('places 3×40ft containers in 3 wagon slots (1 per wagon)', () => {
|
||||
const units = makeUnits('40GP', 40, 3);
|
||||
const slots = [1, 2, 3, 4, 5];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(3);
|
||||
// Each 40ft container gets its own slot
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(2);
|
||||
expect(placements[2]?.sequenceNo).toBe(3);
|
||||
});
|
||||
|
||||
it('handles mixed 20ft and 40ft containers correctly', () => {
|
||||
const units20 = makeUnits('20GP', 20, 2);
|
||||
const units40 = makeUnits('40GP', 40, 1);
|
||||
const units = [...units20, ...units40];
|
||||
const slots = [1, 2, 3, 4, 5];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(3);
|
||||
// First two 20ft containers share slot 1
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(1);
|
||||
// 40ft container gets slot 2
|
||||
expect(placements[2]?.sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to last slot when running out of slots', () => {
|
||||
const units = makeUnits('20GP', 20, 6);
|
||||
const slots = [1, 2]; // Only 2 slots available
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(6);
|
||||
// First 4 units fit in slots 1 and 2
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(1);
|
||||
expect(placements[2]?.sequenceNo).toBe(2);
|
||||
expect(placements[3]?.sequenceNo).toBe(2);
|
||||
// Remaining units fall back to last available slot (slot 2)
|
||||
expect(placements[4]?.sequenceNo).toBe(2);
|
||||
expect(placements[5]?.sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('defaults to 2 containers per wagon when sizeFt is not provided', () => {
|
||||
const units: ContainerUnitRow[] = [
|
||||
{
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BKG-001',
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 0,
|
||||
containerTypeId: 'ct-1',
|
||||
containerTypeCode: '20GP',
|
||||
label: 'Container 1',
|
||||
grossWeightTons: 25,
|
||||
// sizeFt not provided, should default to 2 per wagon
|
||||
},
|
||||
{
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BKG-001',
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 1,
|
||||
containerTypeId: 'ct-1',
|
||||
containerTypeCode: '20GP',
|
||||
label: 'Container 2',
|
||||
grossWeightTons: 25,
|
||||
},
|
||||
];
|
||||
const slots = [1, 2, 3];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(1);
|
||||
});
|
||||
|
||||
it('uses 1 container per wagon for 40ft when sizeFt is 40', () => {
|
||||
const units: ContainerUnitRow[] = [
|
||||
{
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BKG-001',
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 0,
|
||||
containerTypeId: 'ct-1',
|
||||
containerTypeCode: '40GP',
|
||||
label: 'Container 1',
|
||||
grossWeightTons: 25,
|
||||
sizeFt: 40,
|
||||
},
|
||||
{
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BKG-001',
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 1,
|
||||
containerTypeId: 'ct-1',
|
||||
containerTypeCode: '40GP',
|
||||
label: 'Container 2',
|
||||
grossWeightTons: 25,
|
||||
sizeFt: 40,
|
||||
},
|
||||
];
|
||||
const slots = [1, 2, 3];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
// Each 40ft container should get its own slot
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
expect(placements[1]?.sequenceNo).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLocalPlacements', () => {
|
||||
it('returns empty array for valid placements', () => {
|
||||
const units = makeUnits('20GP', 20, 1);
|
||||
const placements = [
|
||||
{
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 0,
|
||||
sequenceNo: 1,
|
||||
containerNumber: 'CNTR123',
|
||||
},
|
||||
];
|
||||
expect(validateLocalPlacements(units, placements)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns error for missing slot', () => {
|
||||
const units = makeUnits('20GP', 20, 1);
|
||||
const placements: ReturnType<typeof autoFillPlacements> = [];
|
||||
const issues = validateLocalPlacements(units, placements);
|
||||
expect(issues.some((i) => i.includes('Slot missing'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error for missing container number', () => {
|
||||
const units = makeUnits('20GP', 20, 1);
|
||||
const placements = [
|
||||
{
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 0,
|
||||
sequenceNo: 1,
|
||||
// No containerNumber or containerId
|
||||
},
|
||||
];
|
||||
const issues = validateLocalPlacements(units, placements);
|
||||
expect(issues.some((i) => i.includes('Enter a container number'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error for duplicate container numbers', () => {
|
||||
const units = makeUnits('20GP', 20, 2);
|
||||
const placements = [
|
||||
{
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 0,
|
||||
sequenceNo: 1,
|
||||
containerNumber: 'CNTR123',
|
||||
},
|
||||
{
|
||||
bookingContainerId: 'bc-1',
|
||||
unitIndex: 1,
|
||||
sequenceNo: 1,
|
||||
containerNumber: 'CNTR123', // Duplicate!
|
||||
},
|
||||
];
|
||||
const issues = validateLocalPlacements(units, placements);
|
||||
expect(issues.some((i) => i.includes('Duplicate container number'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
|
||||
|
||||
export function unitKey(bookingContainerId: string, unitIndex: number) {
|
||||
return `${bookingContainerId}:${unitIndex}`;
|
||||
}
|
||||
|
||||
type ScheduleWagonForPlacements = {
|
||||
sequenceNo: number;
|
||||
allocations?: Array<{
|
||||
containerItems?: Array<{
|
||||
bookingContainerId?: string | null;
|
||||
positionOnWagon?: number | null;
|
||||
containerId?: string | null;
|
||||
containerNumber?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function placementsFromScheduleWagons(
|
||||
wagons: ScheduleWagonForPlacements[],
|
||||
): ContainerPlacement[] {
|
||||
const placements: ContainerPlacement[] = [];
|
||||
|
||||
for (const wagon of wagons) {
|
||||
for (const allocation of wagon.allocations ?? []) {
|
||||
for (const containerItem of allocation.containerItems ?? []) {
|
||||
if (containerItem.bookingContainerId && containerItem.positionOnWagon != null) {
|
||||
placements.push({
|
||||
bookingContainerId: containerItem.bookingContainerId,
|
||||
unitIndex: containerItem.positionOnWagon - 1,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
containerNumber: containerItem.containerNumber ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function mergePlacementsWithSaved(
|
||||
autoFilled: ContainerPlacement[],
|
||||
saved: ContainerPlacement[],
|
||||
): ContainerPlacement[] {
|
||||
const savedMap = new Map(
|
||||
saved.map((placement) => [unitKey(placement.bookingContainerId, placement.unitIndex), placement]),
|
||||
);
|
||||
|
||||
return autoFilled.map((placement) => {
|
||||
const existing = savedMap.get(unitKey(placement.bookingContainerId, placement.unitIndex));
|
||||
if (existing?.containerNumber?.trim()) {
|
||||
return {
|
||||
...placement,
|
||||
containerNumber: existing.containerNumber,
|
||||
containerId: undefined,
|
||||
sealNumber: existing.sealNumber,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
}
|
||||
|
||||
export function autoFillPlacements(
|
||||
units: ContainerUnitRow[],
|
||||
containerSlots: number[],
|
||||
): ContainerPlacement[] {
|
||||
if (!units.length || !containerSlots.length) return [];
|
||||
|
||||
const placements: ContainerPlacement[] = [];
|
||||
let currentSlotIndex = 0;
|
||||
let unitsInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const perWagon = unit.containersPerWagon ?? (unit.sizeFt && unit.sizeFt >= 40 ? 1 : 2);
|
||||
|
||||
if (unitsInCurrentSlot >= perWagon) {
|
||||
currentSlotIndex += 1;
|
||||
unitsInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const sequenceNo =
|
||||
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
|
||||
containerSlots[containerSlots.length - 1] ??
|
||||
containerSlots[0];
|
||||
|
||||
placements.push({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo,
|
||||
});
|
||||
|
||||
unitsInCurrentSlot += 1;
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function validateLocalPlacements(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacement[],
|
||||
): string[] {
|
||||
const issues: string[] = [];
|
||||
const numbers = new Set<string>();
|
||||
|
||||
for (const unit of units) {
|
||||
const placement = placements.find(
|
||||
(p) =>
|
||||
p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex,
|
||||
);
|
||||
if (!placement?.sequenceNo) {
|
||||
issues.push(`Slot missing for ${unit.label}`);
|
||||
continue;
|
||||
}
|
||||
if (!placement.containerNumber?.trim()) {
|
||||
issues.push(`Enter a container number for ${unit.label}`);
|
||||
}
|
||||
if (placement.containerNumber?.trim()) {
|
||||
const normalized = placement.containerNumber.trim().toUpperCase();
|
||||
if (numbers.has(normalized)) {
|
||||
issues.push(`Duplicate container number ${normalized}`);
|
||||
}
|
||||
numbers.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface WagonSlotForPin {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
physicalWagonId?: string | null;
|
||||
wagonType?: { id: string } | null;
|
||||
}
|
||||
|
||||
export function autoFillWagonAssignments(
|
||||
slots: WagonSlotForPin[],
|
||||
wagonOptionsByType: Map<string, Array<{ value: string; label: string }>>,
|
||||
existingAssignments: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
const assignedWagonIds = new Set<string>();
|
||||
|
||||
for (const slot of slots) {
|
||||
const pinnedId = slot.physicalWagonId ?? existingAssignments[slot.id];
|
||||
if (pinnedId) {
|
||||
next[slot.id] = pinnedId;
|
||||
assignedWagonIds.add(pinnedId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const slot of slots) {
|
||||
if (next[slot.id]) continue;
|
||||
|
||||
const typeId = slot.wagonType?.id ?? "";
|
||||
const options = wagonOptionsByType.get(typeId) ?? [];
|
||||
const availableWagon = options.find((option) => !assignedWagonIds.has(option.value));
|
||||
if (availableWagon) {
|
||||
next[slot.id] = availableWagon.value;
|
||||
assignedWagonIds.add(availableWagon.value);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function countFilledSlots(
|
||||
slots: WagonSlotForPin[],
|
||||
assignments: Record<string, string>,
|
||||
): { filled: number; total: number; unfilledSlotNumbers: number[] } {
|
||||
const unfilledSlotNumbers: number[] = [];
|
||||
|
||||
for (const slot of slots) {
|
||||
if (!assignments[slot.id]) {
|
||||
unfilledSlotNumbers.push(slot.sequenceNo);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filled: slots.length - unfilledSlotNumbers.length,
|
||||
total: slots.length,
|
||||
unfilledSlotNumbers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { FreightType } from "@/types/trainScheduling";
|
||||
|
||||
const isContainerFreight = (freightType?: string | null) => freightType === "CONTAINER";
|
||||
|
||||
/** Show container number placement step when train includes container cargo. */
|
||||
export function shouldShowContainerPlacementStep(params: {
|
||||
containerUnitCount: number;
|
||||
scheduleFreightType?: FreightType | string | null;
|
||||
bookingFreightTypes: Array<FreightType | string | null | undefined>;
|
||||
}): boolean {
|
||||
if (params.containerUnitCount > 0) return true;
|
||||
if (isContainerFreight(params.scheduleFreightType)) return true;
|
||||
return params.bookingFreightTypes.some(isContainerFreight);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MantineTheme } from "@mantine/core";
|
||||
|
||||
export const schedulingWorkflow = {
|
||||
stepper: {
|
||||
color: "teal" as const,
|
||||
iconSize: 32,
|
||||
size: "sm" as const,
|
||||
},
|
||||
card: {
|
||||
radius: "xl" as const,
|
||||
padding: "lg" as const,
|
||||
withBorder: true,
|
||||
},
|
||||
heroGradient: (theme: MantineTheme) =>
|
||||
`linear-gradient(135deg, ${theme.colors.teal[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
|
||||
workflowGradient: (theme: MantineTheme) =>
|
||||
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
|
||||
accentColor: "teal" as const,
|
||||
successColor: "teal" as const,
|
||||
warningColor: "yellow" as const,
|
||||
};
|
||||
|
||||
export const schedulingStepMeta = [
|
||||
{ label: "Bookings", description: "Select & preview", icon: "package" },
|
||||
{ label: "Wagon plan", description: "Allocations", icon: "layout" },
|
||||
{ label: "Containers", description: "Map units", icon: "container" },
|
||||
{ label: "Finalize", description: "Depart", icon: "check" },
|
||||
] as const;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Train } from '@/services/trainService';
|
||||
|
||||
export function TrainDetailCard({ train }: { train: Train }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{train.trainNumber || train.code} - {train.trainName || 'Unnamed'}</CardTitle></CardHeader>
|
||||
<CardContent className="grid md:grid-cols-2 gap-4">
|
||||
<div><span className="font-medium">Status:</span> {train.status}</div>
|
||||
<div><span className="font-medium">Capacity:</span> {train.capacityTons} tons</div>
|
||||
<div><span className="font-medium">Origin:</span> {train.originStationId || '-'}</div>
|
||||
<div><span className="font-medium">Destination:</span> {train.destinationStationId || '-'}</div>
|
||||
<div><span className="font-medium">Departure:</span> {train.departureTime ? new Date(train.departureTime).toLocaleString() : '-'}</div>
|
||||
<div><span className="font-medium">Arrival:</span> {train.arrivalTime ? new Date(train.arrivalTime).toLocaleString() : '-'}</div>
|
||||
{train.remarks && <div className="col-span-2"><span className="font-medium">Remarks:</span> {train.remarks}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCreateTrain, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface TrainFormDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
train?: any;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function TrainFormDialog({ trigger, train, onSuccess }: TrainFormDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ code: '', capacityTons: 0, trainNumber: '', trainName: '' });
|
||||
const createTrain = useCreateTrain();
|
||||
const updateTrain = useUpdateTrain();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (train) setForm({
|
||||
code: train.code,
|
||||
capacityTons: train.capacityTons,
|
||||
trainNumber: train.trainNumber || '',
|
||||
trainName: train.trainName || '',
|
||||
});
|
||||
}, [train]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (train) await updateTrain.mutateAsync({ id: train.id, data: form });
|
||||
else await createTrain.mutateAsync(form);
|
||||
toast({ title: train ? 'Train updated' : 'Train created', description: `${form.code} saved.` });
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast({ title: 'Error', description: `Failed to ${train ? 'update' : 'create'} train.`, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger || <Button>New Train</Button>}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{train ? 'Edit Train' : 'Create Train'}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div><Label>Code*</Label><Input required value={form.code} onChange={e => setForm({...form, code: e.target.value})} /></div>
|
||||
<div><Label>Capacity (tons)*</Label><Input type="number" required value={form.capacityTons} onChange={e => setForm({...form, capacityTons: parseFloat(e.target.value)})} /></div>
|
||||
<div><Label>Train Number</Label><Input value={form.trainNumber} onChange={e => setForm({...form, trainNumber: e.target.value})} /></div>
|
||||
<div><Label>Train Name</Label><Input value={form.trainName} onChange={e => setForm({...form, trainName: e.target.value})} /></div>
|
||||
<Button type="submit" disabled={createTrain.isPending || updateTrain.isPending}>Save</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useTrains, useDeleteTrain } from '@/hooks/useTrains';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye, Trash2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function TrainsTable() {
|
||||
const { data: trains, isLoading } = useTrains();
|
||||
const deleteTrain = useDeleteTrain();
|
||||
|
||||
if (isLoading) return <div>Loading trains...</div>;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Number</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Capacity (tons)</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trains?.map(train => (
|
||||
<TableRow key={train.id}>
|
||||
<TableCell>{train.trainNumber || train.code}</TableCell>
|
||||
<TableCell>{train.trainName || '-'}</TableCell>
|
||||
<TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
|
||||
<TableCell>{train.capacityTons}</TableCell>
|
||||
<TableCell className="flex space-x-2">
|
||||
<Link to={`/trains/${train.id}`}>
|
||||
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
|
||||
|
||||
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wagonId, setWagonId] = useState('');
|
||||
const [sequence, setSequence] = useState<number>();
|
||||
const [wagonId, setWagonId] = useState<string | null>(null);
|
||||
const [sequence, setSequence] = useState<number | "">("");
|
||||
const { data: wagons } = useWagons();
|
||||
const assign = useAssignWagonToTrain();
|
||||
const { toast } = useToast();
|
||||
|
||||
const available = wagons?.filter((w:any) => w.status === 'AVAILABLE' || !w.trainId);
|
||||
const available = (wagons ?? []).filter(
|
||||
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
|
||||
);
|
||||
|
||||
const wagonOptions = available.map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
|
||||
}));
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!wagonId) return;
|
||||
await assign.mutateAsync({ wagonId, trainId, sequenceNumber: sequence });
|
||||
toast({ title: 'Assigned', description: 'Wagon attached to train.' });
|
||||
setOpen(false);
|
||||
try {
|
||||
await assign.mutateAsync({
|
||||
wagonId,
|
||||
trainId,
|
||||
sequenceNumber: sequence === "" ? undefined : Number(sequence),
|
||||
});
|
||||
toast({ title: "Wagon attached to train" });
|
||||
setOpen(false);
|
||||
setWagonId(null);
|
||||
setSequence("");
|
||||
} catch {
|
||||
toast({ title: "Failed to assign wagon", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Wagon</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Assign Wagon to Train</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Wagon</Label>
|
||||
<Select value={wagonId} onValueChange={setWagonId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{available?.map((w:any) => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sequence (optional)</Label>
|
||||
<Input type="number" value={sequence ?? ''} onChange={e => setSequence(parseInt(e.target.value) || undefined)} />
|
||||
</div>
|
||||
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="lg"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Assign wagon
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Text fw={600}>Assign wagon to train</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Wagon"
|
||||
placeholder="Select wagon"
|
||||
data={wagonOptions}
|
||||
value={wagonId}
|
||||
onChange={setWagonId}
|
||||
searchable
|
||||
/>
|
||||
<NumberInput
|
||||
label="Sequence (optional)"
|
||||
value={sequence}
|
||||
onChange={(value) => setSequence(value === "" ? "" : Number(value))}
|
||||
min={1}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
Assign
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// src/components/wagons/WagonFormDialog.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
import { useWagonTypes } from '@/hooks/use-wagon-types';
|
||||
import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface WagonFormDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
wagon?: any;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
wagonNumber: '',
|
||||
wagonTypeId: '',
|
||||
tareWeight: 0,
|
||||
maxPayloadWeight: 0,
|
||||
status: 'AVAILABLE',
|
||||
notes: ''
|
||||
});
|
||||
const createWagon = useCreateWagon();
|
||||
const updateWagon = useUpdateWagon();
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (wagon) setForm({
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
wagonTypeId: wagon.wagonTypeId,
|
||||
tareWeight: wagon.tareWeight,
|
||||
maxPayloadWeight: wagon.maxPayloadWeight,
|
||||
status: wagon.status,
|
||||
notes: wagon.notes || ''
|
||||
});
|
||||
}, [wagon]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.wagonNumber || !form.wagonTypeId) {
|
||||
toast({ title: 'Missing required field', description: 'Please select a wagon type.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
|
||||
else await createWagon.mutateAsync(form);
|
||||
toast({ title: wagon ? 'Wagon updated' : 'Wagon created', description: `${form.wagonNumber} saved.` });
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast({ title: 'Error', description: `Failed to ${wagon ? 'update' : 'create'} wagon.`, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger || <Button>New Wagon</Button>}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div><Label>Wagon Number*</Label><Input value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div>
|
||||
<div>
|
||||
<Label>Wagon Type*</Label>
|
||||
<Select
|
||||
value={form.wagonTypeId}
|
||||
disabled={wagonTypesLoading}
|
||||
onValueChange={(value) => {
|
||||
const selectedType = wagonTypes.find((type: any) => type.id === value);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
wagonTypeId: value,
|
||||
maxPayloadWeight: current.maxPayloadWeight > 0
|
||||
? current.maxPayloadWeight
|
||||
: Number(selectedType?.capacityTons ?? current.maxPayloadWeight),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select wagon type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wagonTypes.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{type.code} - {type.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Tare Weight (kg)*</Label><Input type="number" value={form.tareWeight} onChange={e => setForm({...form, tareWeight: Number(e.target.value)})} /></div>
|
||||
<div><Label>Max Payload (kg)*</Label><Input type="number" value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: Number(e.target.value)})} /></div>
|
||||
<div><Label>Status</Label><Input value={form.status} onChange={e => setForm({...form, status: e.target.value})} /></div>
|
||||
<div><Label>Notes</Label><Input value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} /></div>
|
||||
<Button type="submit" disabled={createWagon.isPending || updateWagon.isPending}>Save</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useWagonTypes } from '@/hooks/use-wagon-types';
|
||||
|
||||
interface Wagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
wagonTypeId: string;
|
||||
trainId?: string;
|
||||
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
|
||||
capacity: number;
|
||||
emptyWeight: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface Train {
|
||||
id: string;
|
||||
trainNumber: string;
|
||||
}
|
||||
|
||||
interface WagonFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
wagon?: Wagon | null;
|
||||
trains: Train[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function WagonFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
wagon,
|
||||
trains = [],
|
||||
onSuccess,
|
||||
}: WagonFormDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
const [formData, setFormData] = useState<Partial<Wagon>>({
|
||||
wagonNumber: '',
|
||||
wagonTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
emptyWeight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (wagon) {
|
||||
setFormData(wagon);
|
||||
} else {
|
||||
setFormData({
|
||||
wagonNumber: '',
|
||||
wagonTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
emptyWeight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
}
|
||||
}, [wagon, open]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<Wagon>) =>
|
||||
axios.post(`${API_BASE_URL}/api/wagons`, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Wagon created successfully');
|
||||
onOpenChange(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['wagons'] });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = axios.isAxiosError(error)
|
||||
? error.response?.data?.message || 'Failed to create wagon'
|
||||
: 'Failed to create wagon';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<Wagon>) =>
|
||||
axios.patch(`${API_BASE_URL}/api/wagons/${wagon?.id}`, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Wagon updated successfully');
|
||||
onOpenChange(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['wagons'] });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = axios.isAxiosError(error)
|
||||
? error.response?.data?.message || 'Failed to update wagon'
|
||||
: 'Failed to update wagon';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.wagonNumber || !formData.wagonTypeId) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (wagon?.id) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{wagon ? 'Edit Wagon' : 'Create New Wagon'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="wagonNumber">Wagon Number *</Label>
|
||||
<Input
|
||||
id="wagonNumber"
|
||||
value={formData.wagonNumber || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, wagonNumber: e.target.value })
|
||||
}
|
||||
placeholder="e.g., W001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="wagonTypeId">Type *</Label>
|
||||
<Select
|
||||
value={formData.wagonTypeId || ''}
|
||||
disabled={wagonTypesLoading}
|
||||
onValueChange={(value) => {
|
||||
const selectedType = wagonTypes.find((type: any) => type.id === value);
|
||||
setFormData({
|
||||
...formData,
|
||||
wagonTypeId: value,
|
||||
capacity: formData.capacity && formData.capacity > 0
|
||||
? formData.capacity
|
||||
: Number(selectedType?.capacityTons ?? 0),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="wagonTypeId">
|
||||
<SelectValue placeholder="Select wagon type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wagonTypes.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{type.code} - {type.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="trainId">Train (Optional)</Label>
|
||||
<select
|
||||
id="trainId"
|
||||
value={formData.trainId || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, trainId: e.target.value || undefined })
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select a train...</option>
|
||||
{trains.map(train => (
|
||||
<option key={train.id} value={train.id}>
|
||||
{train.trainNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={formData.status || 'AVAILABLE'}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, status: e.target.value as any })
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="AVAILABLE">Available</option>
|
||||
<option value="IN_USE">In Use</option>
|
||||
<option value="MAINTENANCE">Maintenance</option>
|
||||
<option value="RETIRED">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Capacity *</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
value={formData.capacity || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
capacity: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="emptyWeight">Empty Weight (kg)</Label>
|
||||
<Input
|
||||
id="emptyWeight"
|
||||
type="number"
|
||||
value={formData.emptyWeight || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
emptyWeight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Textarea
|
||||
id="remarks"
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, remarks: e.target.value })
|
||||
}
|
||||
placeholder="Add any additional notes..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{wagon ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,96 @@
|
||||
import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
|
||||
//import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
//import { Button } from '@/components/ui/button';
|
||||
//import { Trash2, GripVertical } from 'lucide-react';
|
||||
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { useMemo } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
import { DataTable } from "@edr/ui-common";
|
||||
|
||||
export function WagonsTable({ trainId }: { trainId: string }) {
|
||||
const { data: wagons, refetch } = useWagonsByTrain(trainId);
|
||||
const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId);
|
||||
const unassign = useUnassignWagon();
|
||||
const reorder = useReorderWagons();
|
||||
const { toast } = useToast();
|
||||
|
||||
const onDragEnd = (result: any) => {
|
||||
if (!result.destination) return;
|
||||
const items = Array.from(wagons || []);
|
||||
const [removed] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, removed);
|
||||
reorder.mutate({ trainId, wagonIds: items.map((w:any) => w.id) });
|
||||
};
|
||||
const columns = useMemo((): ColumnDef<Wagon>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "wagonNumber",
|
||||
header: "Number",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.wagonNumber,
|
||||
},
|
||||
{
|
||||
id: "wagonTypeId",
|
||||
header: "Type",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.wagonTypeId,
|
||||
},
|
||||
{
|
||||
id: "sequenceNumber",
|
||||
header: "Sequence",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.sequenceNumber ?? "—",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label="Unassign">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={unassign.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await unassign.mutateAsync(row.original.id);
|
||||
await refetch();
|
||||
toast({ title: "Wagon unassigned" });
|
||||
} catch {
|
||||
toast({ title: "Failed to unassign wagon", variant: "destructive" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [unassign.isPending, refetch, toast]);
|
||||
|
||||
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
|
||||
if (!isLoading && !wagons.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="md">
|
||||
No wagons assigned to this train.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div></div>
|
||||
// <DragDropContext onDragEnd={onDragEnd}>
|
||||
// <Droppable droppableId="wagons">
|
||||
// {(provided) => (
|
||||
// <Table {...provided.droppableProps} ref={provided.innerRef}>
|
||||
// <TableHeader>
|
||||
// <TableRow>
|
||||
// <TableHead className="w-10"></TableHead>
|
||||
// <TableHead>Number</TableHead>
|
||||
// <TableHead>Type</TableHead>
|
||||
// <TableHead>Sequence</TableHead>
|
||||
// <TableHead>Status</TableHead>
|
||||
// <TableHead>Actions</TableHead>
|
||||
// </TableRow>
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {wagons.map((wagon, idx) => (
|
||||
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
|
||||
// {(provided) => (
|
||||
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
|
||||
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
|
||||
// <TableCell>{wagon.wagonNumber}</TableCell>
|
||||
// <TableCell>{wagon.wagonTypeId}</TableCell>
|
||||
// <TableCell>{wagon.sequenceNumber}</TableCell>
|
||||
// <TableCell>{wagon.status}</TableCell>
|
||||
// <TableCell>
|
||||
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
|
||||
// <Trash2 className="h-4 w-4" />
|
||||
// </Button>
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// )}
|
||||
// </Draggable>
|
||||
// ))}
|
||||
// {provided.placeholder}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// )}
|
||||
// </Droppable>
|
||||
// </DragDropContext>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={wagons}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No wagons assigned"
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user