Merge freight/develop into feature/trains-management

This commit is contained in:
hagiye
2026-06-05 10:25:05 +03:00
41 changed files with 3172 additions and 7 deletions

View File

@@ -7,10 +7,14 @@ import {
Paperclip,
Settings,
SlidersHorizontal,
<<<<<<< HEAD
Train,
Truck,
Container,
Package,
=======
TrainTrack,
>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
@@ -33,6 +37,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -55,6 +60,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <TrainTrack />,
},
...demoItems,
],
},
@@ -233,6 +243,7 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainsPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />

View File

@@ -1,5 +1,6 @@
import type { BookingListFilter } from "@/services/bookings.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const QUERY_KEYS = {
@@ -35,6 +36,16 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", filters ?? {}] as const,
locomotives: () => ["train-scheduling", "locomotives"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
REFERENCE_DATA: "/bookings/reference-data",
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
@@ -111,6 +112,19 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify",
},
LOCOMOTIVES: {
BASE: "/locomotives",
},
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
},
RULE_ENGINE: {
CARGO_TYPES: "/cargo-types",
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,

View File

@@ -76,7 +76,11 @@ export const useContainerTypeOptions = (
enabled = true,
) =>
useQuery({
queryKey: api.ruleEngine.list.queryKey(),
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
includeNone,
}),
queryFn: () =>
api.ruleEngine.list.call({
resource: "container-types",

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
import { useState } from 'react';
import { useTrains, useDeleteTrain, useCreateTrain } from '@/hooks/useTrains';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -35,6 +36,765 @@ const CreateTrainForm = ({ onSuccess }: { onSuccess: () => void }) => {
<div><Label>Train Name</Label><Input value={form.trainName} onChange={e => setForm({...form, trainName: e.target.value})} /></div>
<Button type="submit" disabled={createTrain.isPending}>Save</Button>
</form>
=======
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import toast from 'react-hot-toast';
import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
EligibleContainerBooking,
TrainScheduleFilters,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
const inputClassName =
'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const formatDayInput = (value?: string | null) => {
if (!value) return '';
return value.slice(0, 10);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
const violations = error.response?.data?.violations;
if (Array.isArray(violations)) return violations.join(', ');
}
return fallback;
};
const deriveFromBooking = (
booking: EligibleContainerBooking | undefined,
stations: YardOption[],
) => {
if (!booking) {
return { originStationId: '', destinationStationId: '', scheduleDate: '' };
}
const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
const destinationStationId =
stations.find((station) => station.name === booking.destination)?.id ?? '';
return {
originStationId,
destinationStationId,
scheduleDate: formatDayInput(booking.preferredDepartureDate),
};
};
const TrainsPage = () => {
const qc = useQueryClient();
const [filters, setFilters] = useState<TrainScheduleFilters>({});
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const [scheduleSearch, setScheduleSearch] = useState('');
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
const stationsQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
});
const eligibleQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
queryFn: () => trainSchedulingService.getEligibleBookings(filters),
});
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const eligibleItems = eligibleQuery.data?.items ?? [];
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((schedule) => {
const matchesStatus =
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
if (!matchesStatus) {
return false;
}
if (!query) {
return true;
}
const haystack = [
schedule.id,
schedule.origin ?? '',
schedule.destination ?? '',
schedule.locomotive?.code ?? '',
schedule.status,
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const selectedBookings = useMemo(
() => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
[eligibleItems, selectedBookingIds],
);
const summary = useMemo(() => {
const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
const wagonsNeeded = Math.ceil(totalWeightTons / 70);
const totalLengthMeters = wagonsNeeded * 14;
const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
return {
count: selectedBookings.length,
totalWeightTons,
wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
};
}, [selectedBookings]);
const previewMutation = useMutation({
mutationFn: () => {
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.preview({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
});
},
onSuccess: (data) => {
setPreview(data);
toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
},
onError: (error) => {
toast.error(parseError(error, 'Failed to preview train schedule'));
},
});
const createMutation = useMutation({
mutationFn: () => {
if (!selectedLocomotiveId) {
throw new Error('Please select a locomotive');
}
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.createSchedule({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
locomotiveId: selectedLocomotiveId,
});
},
onSuccess: (data) => {
toast.success('Train schedule created');
setSelectedBookingIds([]);
setSelectedLocomotiveId('');
setPreview(null);
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to create train schedule'));
},
});
const cancelMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
onSuccess: (data) => {
toast.success('Train schedule cancelled');
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to cancel train schedule'));
},
});
const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
setSelectedBookingIds((current) => {
if (checked) {
const next = [...new Set([...current, booking.id])];
if (next.length === 1) {
const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
setFilters((prev) => ({
...prev,
originStationId: prev.originStationId || defaults.originStationId,
destinationStationId: prev.destinationStationId || defaults.destinationStationId,
scheduleDate: prev.scheduleDate || defaults.scheduleDate,
}));
}
return next;
}
return current.filter((id) => id !== booking.id);
});
setPreview(null);
};
const detail = detailQuery.data;
const isBusy = previewMutation.isPending || createMutation.isPending;
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<TrainTrack className="size-7" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
</p>
</div>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
void eligibleQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium">Origin station</label>
<Select
value={filters.originStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
originStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All origins" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All origins</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Destination station</label>
<Select
value={filters.destinationStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
destinationStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All destinations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All destinations</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Schedule date</label>
<input
className={inputClassName}
type="date"
value={filters.scheduleDate ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
scheduleDate: event.target.value || undefined,
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Booking status</label>
<input
className={inputClassName}
placeholder="APPROVED"
value={filters.status ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
status: event.target.value || undefined,
}))
}
/>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
<p className="text-sm text-muted-foreground">
Only container bookings not already assigned to a schedule appear here.
</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{eligibleQuery.data?.count ?? 0} bookings
</span>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Select</th>
<th className="px-3 py-3">Booking</th>
<th className="px-3 py-3">Customer</th>
<th className="px-3 py-3">Container</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Origin</th>
<th className="px-3 py-3">Destination</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{eligibleItems.map((booking) => (
<tr key={booking.id} className="hover:bg-muted/20">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedBookingIds.includes(booking.id)}
onChange={(event) => toggleBooking(booking, event.target.checked)}
/>
</td>
<td className="px-3 py-3 font-medium">{booking.reference}</td>
<td className="px-3 py-3">{booking.customer}</td>
<td className="px-3 py-3">{booking.containerType}</td>
<td className="px-3 py-3">{booking.quantity}</td>
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
<td className="px-3 py-3">{booking.origin}</td>
<td className="px-3 py-3">{booking.destination}</td>
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
<td className="px-3 py-3">{booking.status}</td>
</tr>
))}
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No eligible container bookings matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<h2 className="text-lg font-semibold">Schedule builder</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">{summary.route}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
<p className="mt-2 text-sm font-medium">NW5</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
<p className="mt-2 text-sm font-medium">
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-3">
<Button
className="w-full"
disabled={!selectedBookingIds.length || isBusy}
onClick={() => previewMutation.mutate()}
>
Preview schedule
</Button>
<div className="space-y-2">
<label className="text-sm font-medium">Locomotive</label>
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
<SelectTrigger>
<SelectValue placeholder="Select available locomotive" />
</SelectTrigger>
<SelectContent>
{(locomotivesQuery.data ?? []).map((locomotive) => (
<SelectItem key={locomotive.id} value={locomotive.id}>
{locomotive.code} - {locomotive.maxPullWeightTons}T
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="w-full"
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
onClick={() => createMutation.mutate()}
>
Create schedule
</Button>
</div>
{preview ? (
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Preview result</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
preview.valid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
}`}
>
{preview.valid ? 'Valid' : 'Invalid'}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
</div>
</div>
{preview.violations.length > 0 ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
<ul className="list-disc space-y-1 pl-5">
{preview.violations.map((violation) => (
<li key={violation}>{violation}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Created schedules</h2>
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{filteredSchedules.length} schedules
</span>
</div>
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
<input
className={inputClassName}
placeholder="Search by schedule, route, locomotive, or status"
value={scheduleSearch}
onChange={(event) => setScheduleSearch(event.target.value)}
/>
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All statuses</SelectItem>
<SelectItem value="DRAFT">DRAFT</SelectItem>
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Schedule</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Route</th>
<th className="px-3 py-3">Locomotive</th>
<th className="px-3 py-3">Bookings</th>
<th className="px-3 py-3">Wagons</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Length</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{filteredSchedules.map((schedule) => (
<tr key={schedule.id} className="hover:bg-muted/20">
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
<td className="px-3 py-3">
{schedule.origin} to {schedule.destination}
</td>
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
<td className="px-3 py-3">{schedule.bookingsCount}</td>
<td className="px-3 py-3">{schedule.wagonCount}</td>
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
<td className="px-3 py-3">{schedule.status}</td>
<td className="px-3 py-3">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
View
</Button>
{schedule.status !== 'CANCELLED' ? (
<Button
variant="outline"
size="sm"
onClick={() => cancelMutation.mutate(schedule.id)}
>
Cancel
</Button>
) : null}
</div>
</td>
</tr>
))}
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No train schedules matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
</div>
</section>
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Train schedule detail</DialogTitle>
<DialogDescription>
Inspect the selected schedule, locomotive, wagons, and booking allocations.
</DialogDescription>
</DialogHeader>
{detail ? (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Departure</p>
<p className="mt-2 text-sm font-medium">{formatDate(detail.scheduledDepartureDate)}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Status</p>
<p className="mt-2 text-sm font-medium">{detail.status}</p>
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Locomotive</h3>
<p className="mt-2 text-sm text-muted-foreground">
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
: 'No locomotive attached'}
</p>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
<div className="mt-4 space-y-4">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<div key={wagon.id} className="rounded-xl border border-border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
</p>
<p className="text-sm text-muted-foreground">
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</p>
</div>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Allocated weight</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{wagon.allocations.map((allocation) => (
<tr key={allocation.id}>
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Reference</th>
<th className="px-3 py-2">Customer</th>
<th className="px-3 py-2">Weight</th>
<th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{detail.bookings.map((booking) => (
<tr key={booking.id}>
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
<td className="px-3 py-2">{booking.weightTons} T</td>
<td className="px-3 py-2">{booking.status ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading schedule detail...</p>
)}
</DialogContent>
</Dialog>
</div>
>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
);
};

View File

@@ -0,0 +1,87 @@
import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
LocomotiveRecord,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
interface BookingReferenceDataResponse {
yard?: YardOption[];
}
export const trainSchedulingService = {
getEligibleBookings: async (
filters?: TrainScheduleFilters,
): Promise<EligibleContainerBookingsResponse> => {
const response = await client.get<EligibleContainerBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
{ params: filters },
);
return unwrap(response.data);
},
preview: async (
payload: TrainSchedulePreviewPayload,
): Promise<TrainSchedulePreviewResponse> => {
const response = await client.post<TrainSchedulePreviewResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
payload,
);
return unwrap(response.data);
},
createSchedule: async (
payload: CreateTrainSchedulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
payload,
);
return unwrap(response.data);
},
listSchedules: async (): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
);
return unwrap(response.data);
},
getScheduleById: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.get<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
);
return unwrap(response.data);
},
cancelSchedule: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
{},
);
return unwrap(response.data);
},
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
params: { status: 'AVAILABLE' },
});
return unwrap(response.data);
},
getStations: async (): Promise<YardOption[]> => {
const response = await client.get<BookingReferenceDataResponse>(
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
);
const data = unwrap(response.data);
return data.yard ?? [];
},
};

View File

@@ -0,0 +1,154 @@
export interface YardOption {
id: string;
name: string;
code: string;
country?: string;
}
export interface EligibleContainerBooking {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
}
export interface EligibleContainerBookingsResponse {
count: number;
items: EligibleContainerBooking[];
}
export interface WagonPlanAllocation {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
}
export interface WagonPlanRow {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonPlanAllocation[];
}
export interface TrainSchedulePreviewResponse {
valid: boolean;
violations: string[];
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
bookingIds: string[];
wagonPlan: WagonPlanRow[];
}
export interface LocomotiveRecord {
id: string;
code: string;
name?: string | null;
maxPullWeightTons: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
availableFrom?: string | null;
}
export interface TrainScheduleListItem {
id: string;
scheduleDate: string;
origin: string | null;
destination: string | null;
locomotive:
| {
id: string;
code: string;
name?: string | null;
}
| null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
status: string;
}
export interface TrainScheduleDetail {
id: string;
status: string;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
originStation?: {
id: string;
label?: string;
code?: string;
} | null;
destinationStation?: {
id: string;
label?: string;
code?: string;
} | null;
trainSet?: {
id: string;
status: string;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
locomotive?: {
id: string;
code: string;
name?: string | null;
status: string;
maxPullWeightTons: number;
} | null;
wagons: Array<{
id: string;
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
wagonType?: {
id: string;
code: string;
name: string;
} | null;
allocations: Array<{
id: string;
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
}>;
}>;
} | null;
bookings: Array<{
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
status: string | null;
}>;
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;
scheduleDate?: string;
status?: string;
}
export interface TrainSchedulePreviewPayload {
bookingIds: string[];
scheduleDate: string;
originStationId: string;
destinationStationId: string;
}
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
locomotiveId: string;
}

View File

@@ -50,9 +50,6 @@ export function endpoint<TInput, TResponse>(
if (queryKeyBuilder && input !== undefined) {
return queryKeyBuilder(input as TInput);
}
if (queryKeyBuilder && input === undefined) {
return queryKeyBuilder(undefined as TInput);
}
return input === undefined
? [service, action]
: [service, action, input];
@@ -124,4 +121,4 @@ export function unwrap<T>(response: { data: T } | T): T {
}
return response as T;
}
}