mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Merge pull request #588 from Tria-plc/freight_feature/usermanagement
fix issues
This commit is contained in:
@@ -4,18 +4,21 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FilterX,
|
||||
LayoutList,
|
||||
Package,
|
||||
Plus,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
useBookingList,
|
||||
useBookingListSummary,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import {
|
||||
@@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
const PAYMENT_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Payment pending" },
|
||||
{ value: "PNR_GENERATED", label: "PNR generated" },
|
||||
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
|
||||
{ value: "PAID", label: "Paid" },
|
||||
{ value: "FAILED", label: "Payment failed" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ value: "true", label: "Government" },
|
||||
{ value: "false", label: "Private" },
|
||||
];
|
||||
|
||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
||||
function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -95,10 +127,18 @@ export default function BookingRequestsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter selects (each nullable = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
// Per-tab filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
|
||||
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
const suppressRowClickRef = useRef(false);
|
||||
@@ -118,9 +158,21 @@ export default function BookingRequestsPage() {
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
...(statusFilter ? { statuses: statusFilter } : {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(originYardFilter ? { originYardId: originYardFilter } : {}),
|
||||
...(destinationYardFilter
|
||||
? { destinationYardId: destinationYardFilter }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
|
||||
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
|
||||
};
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
@@ -129,6 +181,14 @@ export default function BookingRequestsPage() {
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
paymentStatusFilter,
|
||||
ownershipFilter,
|
||||
originYardFilter,
|
||||
destinationYardFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
scheduledFrom,
|
||||
scheduledTo,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
@@ -142,6 +202,49 @@ export default function BookingRequestsPage() {
|
||||
refetch: refetchSummary,
|
||||
} = useBookingListSummary(filter);
|
||||
|
||||
// Yard options for the origin/destination filters (shared routes reference list).
|
||||
const { data: yardRefs } = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardRefs ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardRefs],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const activeFilterCount =
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
(paymentStatusFilter ? 1 : 0) +
|
||||
(ownershipFilter ? 1 : 0) +
|
||||
(originYardFilter ? 1 : 0) +
|
||||
(destinationYardFilter ? 1 : 0) +
|
||||
(createdFrom || createdTo ? 1 : 0) +
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
setPaymentStatusFilter(null);
|
||||
setOwnershipFilter(null);
|
||||
setOriginYardFilter(null);
|
||||
setDestinationYardFilter(null);
|
||||
setCreatedFrom(null);
|
||||
setCreatedTo(null);
|
||||
setScheduledFrom(null);
|
||||
setScheduledTo(null);
|
||||
resetPage();
|
||||
}, [resetPage]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toBookingListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -415,18 +518,44 @@ export default function BookingRequestsPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
<MultiSelect
|
||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 200 }}
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All origins"
|
||||
data={yardOptions}
|
||||
value={originYardFilter}
|
||||
onChange={(v) => {
|
||||
setOriginYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All destinations"
|
||||
data={yardOptions}
|
||||
value={destinationYardFilter}
|
||||
onChange={(v) => {
|
||||
setDestinationYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
@@ -434,11 +563,11 @@ export default function BookingRequestsPage() {
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
@@ -446,13 +575,98 @@ export default function BookingRequestsPage() {
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All payment statuses"
|
||||
data={PAYMENT_STATUS_OPTIONS}
|
||||
value={paymentStatusFilter}
|
||||
onChange={(v) => {
|
||||
setPaymentStatusFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created from"
|
||||
value={createdFrom}
|
||||
onChange={(v) => {
|
||||
setCreatedFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={createdTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created to"
|
||||
value={createdTo}
|
||||
onChange={(v) => {
|
||||
setCreatedTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={createdFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled from"
|
||||
value={scheduledFrom}
|
||||
onChange={(v) => {
|
||||
setScheduledFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={scheduledTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled to"
|
||||
value={scheduledTo}
|
||||
onChange={(v) => {
|
||||
setScheduledTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={scheduledFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function ContractRequestDetailPage() {
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
: (contract.company?.name ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
||||
@@ -133,21 +133,21 @@ export default function ContractRequestsPage() {
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{c.customerLabel}
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,10 +17,15 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownWideNarrow,
|
||||
ArrowRight,
|
||||
ArrowUpNarrowWide,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Eye,
|
||||
@@ -47,9 +53,31 @@ import {
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
import type {
|
||||
BatchBoardFilters,
|
||||
BatchBoardSchedule,
|
||||
BatchBoardSortField,
|
||||
TrainScheduleStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
|
||||
DRAFT: { color: "gray", label: "Draft" },
|
||||
SCHEDULED: { color: "blue", label: "Scheduled" },
|
||||
DISPATCHED: { color: "orange", label: "Dispatched" },
|
||||
ARRIVED: { color: "green", label: "Arrived" },
|
||||
CANCELLED: { color: "red", label: "Cancelled" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
@@ -237,7 +265,8 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.8 }}
|
||||
>
|
||||
Freight schedule · {schedule.status}
|
||||
{schedule.scheduleReference ?? "Freight schedule"} ·{" "}
|
||||
{schedule.status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -395,15 +424,73 @@ function CardSkeleton() {
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery(
|
||||
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
|
||||
);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 12 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [windowFilter, setWindowFilter] = useState("ALL");
|
||||
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
|
||||
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
|
||||
const [departureTo, setDepartureTo] = useState<Date | null>(null);
|
||||
|
||||
const schedules = data ?? [];
|
||||
// Every knob maps straight onto the server-side batch-board query — the API
|
||||
// filters, searches, sorts and paginates; this page just renders the page.
|
||||
const filters = useMemo((): BatchBoardFilters => {
|
||||
const endOfDay = (d: Date) =>
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
statuses:
|
||||
statusFilter === "ALL"
|
||||
? undefined
|
||||
: [statusFilter as TrainScheduleStatus],
|
||||
bookingWindowStatus:
|
||||
windowFilter === "ALL"
|
||||
? undefined
|
||||
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
|
||||
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
|
||||
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
};
|
||||
}, [
|
||||
pagination,
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
// Any filter change restarts from the first page.
|
||||
useEffect(() => {
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}, [
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setPagination,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
|
||||
refetchInterval: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const schedules = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = data?.totalPages ?? 1;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
|
||||
@@ -412,33 +499,6 @@ export default function BatchBoardPage() {
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return schedules.filter((s) => {
|
||||
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.status,
|
||||
s.bookingWindowStatus,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedules, search, windowFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
@@ -456,6 +516,11 @@ export default function BatchBoardPage() {
|
||||
<Text size="sm" fw={700} lh={1.2} truncate>
|
||||
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
{row.original.scheduleReference ? (
|
||||
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
|
||||
{row.original.scheduleReference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
@@ -502,6 +567,30 @@ export default function BatchBoardPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const { day, time } = splitDate(row.original.createdAt);
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
header: "Window",
|
||||
@@ -623,7 +712,7 @@ export default function BatchBoardPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Batch Board"
|
||||
subtitle="Active schedules and their booking windows."
|
||||
subtitle="All import schedules — live and historical — with their booking windows."
|
||||
action={
|
||||
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
|
||||
Refresh
|
||||
@@ -635,21 +724,21 @@ export default function BatchBoardPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Active schedules",
|
||||
value: schedules.length,
|
||||
hint: "on the board right now",
|
||||
label: "Schedules",
|
||||
value: total,
|
||||
hint: "matching the current filters",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
label: "Open windows",
|
||||
value: summary.openWindows,
|
||||
hint: "accepting bookings",
|
||||
hint: "accepting bookings (this page)",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
label: "Bookings in play",
|
||||
value: summary.totalBookings,
|
||||
hint: `${summary.totalWagons} wagons allocated`,
|
||||
hint: `${summary.totalWagons} wagons allocated (this page)`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
@@ -661,24 +750,96 @@ export default function BatchBoardPage() {
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
searchPlaceholder="Search train, route, station, loco…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => v && setStatusFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "ARRIVED", label: "Arrived" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs from"
|
||||
value={departureFrom}
|
||||
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs to"
|
||||
value={departureTo}
|
||||
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={sortBy}
|
||||
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
|
||||
data={[
|
||||
{ value: "createdAt", label: "Sort: Created" },
|
||||
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
|
||||
{ value: "trainNumber", label: "Sort: Train no." },
|
||||
{ value: "status", label: "Sort: Status" },
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
label={sortOrder === "DESC" ? "Newest / Z–A first" : "Oldest / A–Z first"}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={36}
|
||||
radius="lg"
|
||||
aria-label="Toggle sort direction"
|
||||
onClick={() =>
|
||||
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
|
||||
}
|
||||
>
|
||||
{sortOrder === "DESC" ? (
|
||||
<ArrowDownWideNarrow size={16} />
|
||||
) : (
|
||||
<ArrowUpNarrowWide size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
@@ -686,7 +847,7 @@ export default function BatchBoardPage() {
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paged}
|
||||
data={schedules}
|
||||
status={tableStatus}
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
@@ -699,12 +860,12 @@ export default function BatchBoardPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No active schedules"
|
||||
emptyMessage="No schedules match the current filters"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filtered.length,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -727,7 +888,7 @@ export default function BatchBoardPage() {
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : filtered.length === 0 ? (
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" p={48} m="md" bg="gray.0">
|
||||
<Stack align="center" gap="sm">
|
||||
<Box
|
||||
@@ -745,20 +906,52 @@ export default function BatchBoardPage() {
|
||||
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
<Text fw={700} c="gray.7">
|
||||
No active schedules
|
||||
No schedules match the current filters
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
||||
Schedules with an open booking window appear here. Create or activate a
|
||||
schedule to get started.
|
||||
Every import schedule — live and historical — appears here. Loosen the
|
||||
filters or clear the search to see more.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{filtered.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group justify="space-between" px="md" pb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} schedule{total === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex === 0}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Page {pagination.pageIndex + 1} of {pageCount}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex + 1 >= pageCount}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hash,
|
||||
Hourglass,
|
||||
Layers,
|
||||
Package,
|
||||
@@ -621,6 +622,9 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Composition data only changes through mutations, which invalidate the
|
||||
// whole train-scheduling root — no need to refetch on remounts in between.
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -744,7 +748,13 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
{
|
||||
label:
|
||||
data.scheduleReference ??
|
||||
data.trainNumber ??
|
||||
data.routeName ??
|
||||
"Schedule",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -791,6 +801,11 @@ export default function BatchScheduleDetailPage() {
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
{data.scheduleReference ? (
|
||||
<HeroChip icon={<Hash size={12} />}>
|
||||
{data.scheduleReference}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
{data.windowPhase ? (
|
||||
<WindowPhasePill
|
||||
|
||||
Reference in New Issue
Block a user