automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -53,10 +53,10 @@ const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg";
const fieldClass =
"h-11 w-full rounded-lg border border-gray-200 bg-[#eef4f8] px-4 text-sm text-gray-900 placeholder:text-gray-400 outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/15";
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
const primaryButtonClass =
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60";
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
const LeftPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
@@ -89,7 +89,7 @@ const RightPanelDecor = () => (
);
const LeftPanel = () => (
<div className="relative flex h-36 shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] sm:h-44 md:h-52 lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
<img
src={LOGIN_IMAGE}
alt="Ethio Djibouti Railway"
@@ -176,12 +176,13 @@ const LoginPage = () => {
setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password });
console.log(result)
if (result.mfaRequired) {
setNeedsMfa(true);
return;
}
navigate("/dashboard/overview", { replace: true });
// navigate("/dashboard/overview", { replace: true });
} catch {
setError("Unable to sign in with those credentials.");
} finally {
@@ -274,7 +275,7 @@ const LoginPage = () => {
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-primary focus:ring-primary/20 focus:ring-offset-0"
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded-md border-gray-300 text-primary transition-colors focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
/>
<span className="text-sm leading-snug text-gray-600">
I agree to EDR Freight{" "}
@@ -386,7 +387,7 @@ const LoginPage = () => {
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
<div className="my-auto w-full rounded-2xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
{!needsMfa ? loginForm : mfaForm}
</div>
</div>

View File

@@ -40,6 +40,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
@@ -47,11 +50,14 @@ export default function BookingContractPage() {
enabled: Boolean(id),
});
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
? "CUSTOMER"
: data?.canSignStaff
? "STAFF"
: null;
// Backoffice only ever signs as STAFF — customers sign in the portal.
const canSign = Boolean(data?.canSignStaff);
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({
mutationFn: (payload: SignContractPayload) =>
@@ -88,16 +94,22 @@ export default function BookingContractPage() {
};
const openSign = () => {
setSignerName("");
// Prefill from the saved signature when available so the user only has to
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signRole || !signatureData || !signerName.trim()) return;
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: signRole,
signatureImageBase64: signatureData,
role: "STAFF",
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -152,10 +164,10 @@ export default function BookingContractPage() {
<Download className="size-4" />
Download PDF
</Button>
{signRole && (
{canSign && (
<Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" />
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
@@ -179,11 +191,11 @@ export default function BookingContractPage() {
<Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogTitle>Staff signature</DialogTitle>
<DialogDescription>
Sign to execute the contract for {data.reference}.
{usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
: `Sign to execute the contract for ${data.reference}.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -196,7 +208,32 @@ export default function BookingContractPage() {
placeholder="As shown on the contract"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
{usingSaved ? (
<div className="space-y-2">
<Label>Saved signature</Label>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={savedSignatureImage ?? undefined}
alt="Saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -205,13 +242,15 @@ export default function BookingContractPage() {
<Button
disabled={
signMutation.isPending ||
!signatureData ||
(!usingSaved && !signatureData) ||
!signerName.trim()
}
onClick={confirmSign}
>
{signMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : usingSaved ? (
"Approve & sign"
) : (
"Confirm signature"
)}

View File

@@ -1,21 +1,21 @@
import { useParams, useNavigate } from "react-router-dom";
import { Container, Stack, Grid } from "@mantine/core";
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import {
BookingApprovalCard,
BookingContainersCard,
BookingDetailToolbar,
BookingDocumentsCard,
BookingFactsCard,
BookingLifecycleStepper,
BookingPaymentCard,
BookingPaymentCountdownCard,
BookingReviewNotesCard,
BookingRouteCard,
detailStyles,
type BookingDetailView,
BookingDetailToolbar,
BookingDetailHeader,
BookingLifecycleStepper,
BookingRouteCard,
BookingContainersCard,
BookingApprovalCard,
BookingReviewNotesCard,
BookingPaymentCard,
BookingFactsCard,
BookingDocumentsCard,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
@@ -25,8 +25,9 @@ const BookingDetailPage = () => {
const booking: BookingDetailView = {
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
reference: "BKG-2026-001456",
status: "IN_TRANSIT",
status: "SELECTED_FOR_BATCH",
scheduledDate: "2026-06-15",
paymentDeadline: "2026-06-18T17:00:00Z",
totalAmount: 15750.5,
paymentCurrency: "USD",
paymentStatus: "PAID",
@@ -100,7 +101,9 @@ const BookingDetailPage = () => {
};
const approvalSteps = booking.approvalSteps ?? [];
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length;
const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED",
).length;
const totalSteps = approvalSteps.length;
return (
@@ -115,7 +118,7 @@ const BookingDetailPage = () => {
{ label: booking.reference },
]}
/>
{/*
{/*
<BookingDetailHeader
booking={booking}
approvedCount={approvedCount}
@@ -124,13 +127,18 @@ const BookingDetailPage = () => {
<BookingLifecycleStepper status={booking.status} />
<Grid gutter="lg">
<Grid>
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteCard booking={booking} />
<BookingContainersCard containers={booking.bookingContainers ?? []} />
<BookingApprovalCard steps={approvalSteps} approvedCount={approvedCount} />
<BookingContainersCard
containers={booking.bookingContainers ?? []}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}
/>
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
</Stack>
</Grid.Col>
@@ -138,6 +146,12 @@ const BookingDetailPage = () => {
{/* RIGHT — summary sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{booking.status === "SELECTED_FOR_BATCH" &&
booking.paymentDeadline && (
<BookingPaymentCountdownCard
paymentDeadline={booking.paymentDeadline}
/>
)}
<BookingPaymentCard
totalAmount={booking.totalAmount}
currency={booking.paymentCurrency}

View File

@@ -17,18 +17,33 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -36,6 +51,14 @@ export default function BookingRequestDetailPage() {
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<Box style={detailStyles.page}>
@@ -129,6 +152,10 @@ export default function BookingRequestDetailPage() {
titleColor={statusMeta.color}
/>
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gutter="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
@@ -143,6 +170,12 @@ export default function BookingRequestDetailPage() {
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
@@ -150,6 +183,7 @@ export default function BookingRequestDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
<BookingActionsToolbar booking={booking} mutations={mutations} />

View File

@@ -1,32 +1,15 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
FileText,
Inbox,
LayoutList,
Package,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react";
import {
Container,
Stack,
Group,
Title,
Text,
Card,
TextInput,
ActionIcon,
Badge as MantineBadge,
Button as MantineButton,
ThemeIcon,
Paper,
Tabs,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -35,15 +18,22 @@ import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
import {
useBookingDetail,
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
@@ -53,8 +43,6 @@ import {
type ColumnDef,
usePagination,
Badge,
Button,
Input,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
@@ -63,11 +51,16 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
return match.statuses.join(",");
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -77,20 +70,54 @@ export default function BookingRequestsPage() {
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const isOperationsTab = activeTab === "operations";
const filter: BookingListFilter = useMemo(
() => ({
const filter: BookingListFilter = useMemo(() => {
if (isOperationsTab) {
if (operationsSubTab === "ready") {
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
assignedToSchedule: "false",
sortBy: "isGovernment",
sortOrder: "DESC",
tab: activeTab,
};
}
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "SCHEDULED,DISPATCHED",
sortBy: "scheduledDate",
sortOrder: "ASC",
tab: activeTab,
};
}
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
};
}, [
isOperationsTab,
operationsSubTab,
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
@@ -115,14 +142,24 @@ export default function BookingRequestsPage() {
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const statValue = (value: number | undefined) =>
summaryLoading ? "—" : (value ?? 0);
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleAllocateFromQueue = useCallback(
(ids: string[]) => {
const selected = rows.filter((b) => ids.includes(b.id));
const sorted = [...selected].sort(
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
);
setAllocateIds(sorted.map((b) => b.id));
setAllocateOpen(true);
},
[rows],
);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
@@ -190,7 +227,11 @@ export default function BookingRequestsPage() {
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
<BookingStatusBadge
status={row.original.status}
consolidated={Boolean(row.original.consolidationPartnerId)}
partnerReference={row.original.consolidationPartnerReference}
/>
</div>
),
meta: {
@@ -259,108 +300,25 @@ export default function BookingRequestsPage() {
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
{/*
<Card
p="lg"
radius="lg"
withBorder
mb="xl"
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<Group justify="space-between" align="flex-start">
<Group gap="md" align="flex-start">
<ThemeIcon
size="lg"
radius="lg"
color="green"
variant="light"
>
<Inbox size={28} />
</ThemeIcon>
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Operations
</Text>
<Title order={1} size="h2">
Booking Requests
</Title>
<Text size="sm" c="dimmed" maw="500px">
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
</Text>
</Stack>
</Group>
<MantineButton
variant="light"
color="green"
leftSection={<RefreshCw size={18} />}
disabled={isFetching}
onClick={handleRefresh}
loading={isFetching}
>
Refresh
</MantineButton>
</Group>
</Card> */}
<div className="mt-6"></div>
<Stack gap="lg">
<BookingStatGrid
items={[
{
label: "In queue",
value: statValue(metrics?.inQueue),
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: statValue(metrics?.onThisPage),
hint: "Current page",
icon: FileText,
},
{
label: "Needs action",
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
<Stack gap="lg" mt="md">
<BookingRequestsHeader
metrics={metrics}
tabs={tabCounts}
loading={summaryLoading}
isFetching={isFetching}
onCreate={() => navigate("/dashboard/booking-requests/new")}
onRefresh={handleRefresh}
/>
<Paper
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
/>
</Paper>
counts={tabCounts}
/>
<Card
p="md"
@@ -399,7 +357,39 @@ export default function BookingRequestsPage() {
</Text>
</Group>
{showEmpty ? (
{isOperationsTab ? (
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
) : showEmpty ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
@@ -438,6 +428,19 @@ export default function BookingRequestsPage() {
</Stack>
</Card>
</Stack>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
</Container>
</div>
);

View File

@@ -1,12 +1,848 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ActionIcon,
Badge,
Box,
Button,
Container,
Divider,
Grid,
Group,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
Boxes,
CalendarClock,
Container as ContainerIcon,
Flame,
Info,
Layers,
MapPin,
Package,
Plus,
Settings2,
Ship,
Trash2,
Weight,
} from "lucide-react";
import toast from "react-hot-toast";
const NewBookingPage = () => {
return (
<FeaturePlaceholder
title="Create Booking"
description="Capture and validate new freight bookings from the backoffice workflow."
/>
);
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
interface CompanyOption {
id: string;
name?: string | null;
tin?: string | null;
email?: string | null;
}
type FreightType = "CONTAINER" | "BULK";
interface RefNamed {
id: string;
name: string;
code: string;
country?: string;
}
interface RefContainerType {
id: string;
name: string;
code: string;
is_reefer?: boolean;
wagons_per_unit?: number;
}
interface RefContainerGroup {
size: string;
types: RefContainerType[];
}
interface RefCargoChild {
id: string;
name: string;
code: string;
show_free_text_box?: boolean;
}
interface RefCargoGroup {
id: string;
name: string;
code: string;
children?: RefCargoChild[];
}
interface ReferenceData {
yard?: RefNamed[];
service?: RefNamed[];
shipping_line?: RefNamed[];
containers?: RefContainerGroup[];
cargo_type?: RefCargoGroup[];
}
interface ContainerLine {
key: string;
containerTypeId: string | null;
quantity: number;
vgmPerUnitTons: number;
}
let lineCounter = 0;
const newLine = (): ContainerLine => ({
key: `line-${lineCounter++}`,
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 20,
});
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
function deriveTradeDirectionFromYards(
origin?: RefNamed | null,
destination?: RefNamed | null,
): TradeDirection | null {
const originCountry = origin?.country?.trim();
const destinationCountry = destination?.country?.trim();
if (!originCountry || !destinationCountry) return null;
if (originCountry === "Djibouti") return "IMPORT";
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
return "DOMESTIC";
}
const tradeDirectionLabel: Record<TradeDirection, string> = {
IMPORT: "Import",
EXPORT: "Export",
DOMESTIC: "Domestic",
};
export default NewBookingPage;
const parseBookingError = (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;
}
return fallback;
};
/** Section card with a colored icon chip header. */
function FormSection({
icon: Icon,
title,
subtitle,
accent = "green",
right,
children,
}: {
icon: typeof Package;
title: string;
subtitle?: string;
accent?: string;
right?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
<Box style={{ height: 3, background: `linear-gradient(90deg, var(--mantine-color-${accent}-5), var(--mantine-color-${accent}-7))` }} />
<Group justify="space-between" px="lg" py="md" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-gray-1)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color={accent}>
<Icon size={17} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
) : null}
</Box>
</Group>
{right}
</Group>
<Box p="lg">{children}</Box>
</Paper>
);
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [companyId, setCompanyId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
const [lines, setLines] = useState<ContainerLine[]>([newLine()]);
// bulk freight
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const [cargoFreeText, setCargoFreeText] = useState("");
const [bulkWeight, setBulkWeight] = useState<number>(100);
// extra options
const [equipmentReturn, setEquipmentReturn] = useState("NA");
const [isHazardous, setIsHazardous] = useState(false);
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const [firstMilePickupAddress, setFirstMile] = useState("");
const [lastMileDeliveryAddress, setLastMile] = useState("");
const { data: refData, isLoading } = useQuery({
queryKey: ["bookings", "reference-data"],
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
});
const { data: companies, isLoading: companiesLoading } = useQuery({
queryKey: ["companies", "list"],
queryFn: async () => {
const res = await api.get(URL_CONSTANTS.COMPANIES.BASE);
return unwrap(res.data) as CompanyOption[];
},
});
const companyOptions = (companies ?? []).map((c) => ({
value: c.id,
label: c.name || c.email || c.tin || c.id,
}));
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
const effectiveDepartureIso = selectedSchedule
? new Date(selectedSchedule.scheduleDate).toISOString()
: scheduledDate
? new Date(scheduledDate).toISOString()
: "";
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
useEffect(() => {
setTrainScheduleId(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const containerGroupData = useMemo(
() =>
(refData?.containers ?? []).map((g) => ({
group: g.size,
items: g.types.map((t) => ({
value: t.id,
label: `${t.code}${t.name && t.name !== t.code ? `${t.name}` : ""}`,
})),
})),
[refData?.containers],
);
const { cargoData, freeTextById } = useMemo(() => {
const groups = refData?.cargo_type ?? [];
const freeText = new Map<string, boolean>();
const data = groups.map((g) => {
if (g.children?.length) {
g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box)));
return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
}
return { value: g.id, label: g.name };
});
return { cargoData: data, freeTextById: freeText };
}, [refData?.cargo_type]);
const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false;
// ---- derived totals ----
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
// ---- validation ----
const lineValid = (l: ContainerLine) =>
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
const removeLine = (key: string) =>
setLines((prev) => (prev.length === 1 ? prev : prev.filter((l) => l.key !== key)));
const createMutation = useMutation({
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
companyId: isGovernment ? undefined : companyId || undefined,
freightType,
contractType: "NEW",
equipmentReturn,
tradeDirection: tradeDirection!,
paymentCurrency,
isHazardous,
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
cargoTotalWeightVgm,
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined,
containers:
freightType === "CONTAINER"
? lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: l.vgmPerUnitTons,
}))
: undefined,
}),
onSuccess: async (booking) => {
if (isGovernment) {
await bookingsService.governmentExpedite(booking.id);
toast.success("Government booking created and expedited to scheduling");
} else {
toast.success("Booking created as draft");
}
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
});
return (
<Container size="xl" py="lg">
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: "Create" },
]}
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")}
>
Back to list
</Button>
</Group>
<Grid gutter="lg" mt="lg">
{/* LEFT — form */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="green">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
) : (
<Select
label="Customer"
placeholder="Select company"
data={companyOptions}
value={companyId}
onChange={setCompanyId}
searchable
required
disabled={companiesLoading}
nothingFoundMessage="No companies found"
/>
)}
<Box>
<Text size="sm" fw={500} mb={6}>
Freight type
</Text>
<SegmentedControl
fullWidth
value={freightType}
onChange={(v) => setFreightType(v as FreightType)}
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
/>
</Box>
</Stack>
</FormSection>
<FormSection icon={MapPin} title="Route & service" subtitle="Origin, destination and service type" accent="blue">
<Stack gap="md">
<Group grow align="flex-start">
<Select
label="Origin yard"
placeholder="Select origin"
data={yards}
value={originYardId}
onChange={(v) => {
setOriginYardId(v);
setTrainScheduleId(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as destination" : undefined}
/>
<Select
label="Destination yard"
placeholder="Select destination"
data={yards}
value={destinationYardId}
onChange={(v) => {
setDestinationYardId(v);
setTrainScheduleId(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
/>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
</Text>
) : null}
<Group grow align="flex-end">
<Select
label="Service type"
placeholder="Select service"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<Box>
<Text size="sm" fw={500} mb={4}>
Trade direction
</Text>
<Badge
size="lg"
variant="light"
color={
tradeDirection === "DOMESTIC"
? "grape"
: tradeDirection === "EXPORT"
? "orange"
: "blue"
}
>
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
</Badge>
</Box>
</Group>
</Stack>
</FormSection>
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
<Group grow align="flex-start">
{selectedSchedule ? (
<TextInput
label="Departure"
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
readOnly
description="Taken from the selected train schedule"
/>
) : (
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
)}
<Select
label="Payment currency"
data={[
{ value: "ETB", label: "ETB — Birr" },
{ value: "USD", label: "USD — Dollar" },
]}
value={paymentCurrency}
onChange={(v) => setPaymentCurrency(v ?? "ETB")}
/>
</Group>
</FormSection>
{/* Cargo */}
{freightType === "CONTAINER" ? (
<FormSection
icon={ContainerIcon}
title="Container lines"
subtitle="Add one row per container type"
accent="teal"
right={
<Badge variant="light" color="teal" radius="sm">
{totalContainers} container{totalContainers === 1 ? "" : "s"}
</Badge>
}
>
<Stack gap="sm">
{lines.map((line, idx) => {
const invalid = !lineValid(line);
return (
<Paper
key={line.key}
p="sm"
radius="md"
withBorder
style={{
borderColor: invalid ? "var(--mantine-color-gray-3)" : "var(--mantine-color-teal-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Group align="flex-end" gap="sm" wrap="nowrap">
<Text fw={700} c="dimmed" size="sm" w={22} ta="center" style={{ flexShrink: 0 }}>
{idx + 1}
</Text>
<Select
label="Container type"
placeholder="Select type"
data={containerGroupData}
value={line.containerTypeId}
onChange={(v) => updateLine(line.key, { containerTypeId: v })}
searchable
disabled={isLoading}
style={{ flex: 2, minWidth: 160 }}
/>
<NumberInput
label="Qty"
value={line.quantity}
onChange={(v) => updateLine(line.key, { quantity: Number(v) || 0 })}
min={1}
step={1}
style={{ width: 90, flexShrink: 0 }}
/>
<NumberInput
label="VGM / unit"
value={line.vgmPerUnitTons}
onChange={(v) => updateLine(line.key, { vgmPerUnitTons: Number(v) || 0 })}
min={0}
suffix=" t"
decimalScale={2}
style={{ width: 130, flexShrink: 0 }}
/>
<Stack gap={2} style={{ width: 92, flexShrink: 0 }}>
<Text size="xs" c="dimmed">
Line total
</Text>
<Text fw={700} size="sm">
{fmtTons((line.quantity || 0) * (line.vgmPerUnitTons || 0))}
</Text>
</Stack>
<Tooltip label={lines.length === 1 ? "At least one line" : "Remove line"}>
<ActionIcon
variant="subtle"
color="red"
disabled={lines.length === 1}
onClick={() => removeLine(line.key)}
style={{ flexShrink: 0 }}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
);
})}
<Group justify="space-between">
<Button
variant="light"
color="teal"
size="compact-sm"
leftSection={<Plus size={15} />}
onClick={() => setLines((prev) => [...prev, newLine()])}
>
Add container line
</Button>
<Text size="sm" c="dimmed">
Total VGM:{" "}
<Text span fw={700} c="dark">
{fmtTons(containerWeight)}
</Text>
</Text>
</Group>
</Stack>
</FormSection>
) : (
<FormSection icon={Boxes} title="Bulk cargo" subtitle="Select the bulk cargo type and total weight" accent="orange">
<Stack gap="md">
<Select
label="Cargo type"
placeholder="Select bulk cargo type"
data={cargoData}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
disabled={isLoading}
/>
{showFreeText ? (
<Textarea
label="Cargo description"
placeholder="Describe the cargo"
autosize
minRows={2}
value={cargoFreeText}
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
/>
) : null}
<NumberInput
label="Total weight (VGM)"
value={bulkWeight}
onChange={(v) => setBulkWeight(Number(v) || 0)}
min={0}
suffix=" t"
decimalScale={2}
/>
</Stack>
</FormSection>
)}
<FormSection icon={Settings2} title="Additional details" subtitle="Optional — equipment, handling and references" accent="indigo">
<Stack gap="md">
<Group grow>
<Select
label="Equipment return"
data={[
{ value: "NA", label: "Not applicable" },
{ value: "WITH_RETURN", label: "With return" },
{ value: "WITHOUT_RETURN", label: "Without return" },
]}
value={equipmentReturn}
onChange={(v) => setEquipmentReturn(v ?? "NA")}
/>
<Select
label="Shipping line (optional)"
placeholder="Select shipping line"
data={shippingLines}
value={shippingLineId}
onChange={setShippingLineId}
searchable
clearable
disabled={isLoading}
/>
</Group>
<Group grow>
<TextInput
label="First-mile pickup (optional)"
placeholder="Pickup address"
value={firstMilePickupAddress}
onChange={(e) => setFirstMile(e.currentTarget.value)}
/>
<TextInput
label="Last-mile delivery (optional)"
placeholder="Delivery address"
value={lastMileDeliveryAddress}
onChange={(e) => setLastMile(e.currentTarget.value)}
/>
</Group>
<Switch
label="Hazardous cargo"
checked={isHazardous}
onChange={(e) => setIsHazardous(e.currentTarget.checked)}
thumbIcon={isHazardous ? <Flame size={12} /> : undefined}
color="red"
/>
</Stack>
</FormSection>
</Stack>
</Grid.Col>
{/* RIGHT — sticky summary */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 16 }}>
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group gap="sm" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<Weight size={17} />
</ThemeIcon>
<Text fw={700}>Summary</Text>
</Group>
<Group gap="xs" mb="md">
<Badge variant="light" color={freightType === "BULK" ? "orange" : "teal"} radius="sm">
{freightType === "BULK" ? "Bulk" : "Container"}
</Badge>
<Badge variant="light" color="blue" radius="sm">
{tradeDirection}
</Badge>
{isGovernment ? (
<Badge variant="light" color="grape" radius="sm">
Government
</Badge>
) : null}
{isHazardous ? (
<Badge variant="light" color="red" radius="sm" leftSection={<Flame size={10} />}>
Hazardous
</Badge>
) : null}
</Group>
<Stack gap={8}>
{freightType === "CONTAINER" ? (
<>
<SummaryRow label="Container lines" value={String(lines.length)} />
<SummaryRow label="Total containers" value={String(totalContainers)} />
</>
) : null}
<SummaryRow label="Total VGM weight" value={fmtTons(cargoTotalWeightVgm)} strong />
<Divider my={4} />
<SummaryRow
label="Route"
value={
originYardId && destinationYardId
? `${yards.find((y) => y.value === originYardId)?.label ?? "—"}${
yards.find((y) => y.value === destinationYardId)?.label ?? "—"
}`
: "Not set"
}
/>
<SummaryRow
label="Departure"
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"}
/>
</Stack>
{sameYard ? (
<Group gap={6} mt="md" c="red.7">
<AlertTriangle size={14} />
<Text size="xs">Origin and destination must differ.</Text>
</Group>
) : null}
<Stack gap="sm" mt="lg">
<Button
size="md"
fullWidth
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
leftSection={<Ship size={18} />}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
Cancel
</Button>
</Stack>
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
<Text size="xs" c="dimmed">
{isGovernment
? "Government bookings skip the commercial 3-hour hold and enter the priority lane."
: "Overweight container lines are allowed here and flagged later at scheduling."}
</Text>
</Group>
</Paper>
</Box>
</Grid.Col>
</Grid>
</Container>
);
}
function SummaryRow({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" fw={strong ? 700 : 500} ta="right" style={{ minWidth: 0 }} truncate>
{value}
</Text>
</Group>
);
}

View File

@@ -1,35 +0,0 @@
import { useCargoes } from '@/hooks/useCargoes';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function CargoesPage() {
const { data: cargoes, refetch, isLoading } = useCargoes();
if (isLoading) return <div>Loading cargoes...</div>;
return (
<Card>
<CardHeader><CardTitle>All Cargoes</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{cargoes?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.cargoReference}</TableCell>
<TableCell>{c.description || '-'}</TableCell>
<TableCell>{c.quantity}</TableCell>
<TableCell>{c.weight} kg</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
<TableCell>
{c.status === 'PENDING' && <LoadCargoDialog cargoId={c.id} onSuccess={() => refetch()} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -1,314 +0,0 @@
import { useState, useMemo } from 'react';
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Trash2, Edit, Plus, Search, AlertCircle } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import CargoFormDialog from '@/components/cargoes/CargoFormDialog';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
createdAt: Date;
updatedAt: Date;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoesPageEnhanced() {
const { data: cargoes = [], isLoading, refetch } = useCargoes();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCargo, setEditingCargo] = useState<Cargo | null>(null);
const deleteMutation = useMutation({
mutationFn: (cargoId: string) =>
axios.delete(`${API_BASE_URL}/api/cargoes/${cargoId}`),
onSuccess: () => {
toast.success('Cargo deleted successfully');
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargo'
: 'Failed to delete cargo';
toast.error(message);
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: string[]) =>
Promise.all(ids.map(id => axios.delete(`${API_BASE_URL}/api/cargoes/${id}`))),
onSuccess: () => {
toast.success('Cargoes deleted successfully');
setSelectedIds(new Set());
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargoes'
: 'Failed to delete cargoes';
toast.error(message);
},
});
const filteredCargoes = useMemo(() => {
let result = cargoes;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
result = result.filter(
cargo =>
cargo.cargoReference?.toLowerCase().includes(lower) ||
cargo.description?.toLowerCase().includes(lower)
);
}
if (statusFilter) {
result = result.filter(cargo => cargo.status === statusFilter);
}
return result;
}, [cargoes, searchTerm, statusFilter]);
const toggleSelect = (cargoId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(cargoId)) {
newSelected.delete(cargoId);
} else {
newSelected.add(cargoId);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCargoes.map(c => c.id)));
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingCargo(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
};
const handleEdit = (cargo: Cargo) => {
setEditingCargo(cargo);
setIsFormOpen(true);
};
const handleDelete = (cargoId: string) => {
if (window.confirm('Are you sure you want to delete this cargo?')) {
deleteMutation.mutate(cargoId);
}
};
const handleBulkDelete = () => {
if (selectedIds.size === 0) {
toast.error('Please select at least one cargo');
return;
}
if (window.confirm(`Delete ${selectedIds.size} cargo(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedIds));
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-gray-100 text-gray-800';
case 'LOADED':
return 'bg-blue-100 text-blue-800';
case 'IN_TRANSIT':
return 'bg-purple-100 text-purple-800';
case 'DELIVERED':
return 'bg-green-100 text-green-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const statuses = ['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED'];
if (isLoading) {
return <div className="p-6">Loading cargoes...</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Cargoes Management</h1>
<Button onClick={() => {
setEditingCargo(null);
setIsFormOpen(true);
}}>
<Plus className="mr-2 h-4 w-4" />
New Cargo
</Button>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by reference or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
{statuses.map(status => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 p-3 rounded-md">
<span className="text-sm text-gray-600">{selectedIds.size} selected</span>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleteMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Cargoes Table */}
<Card>
<CardHeader>
<CardTitle>All Cargoes ({filteredCargoes.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredCargoes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<AlertCircle className="mr-2 h-5 w-5" />
No cargoes found
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0}
onChange={toggleSelectAll}
className="rounded"
/>
</TableHead>
<TableHead>Cargo Reference</TableHead>
<TableHead>Description</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Weight (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCargoes.map((cargo) => (
<TableRow key={cargo.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(cargo.id)}
onChange={() => toggleSelect(cargo.id)}
className="rounded"
/>
</TableCell>
<TableCell className="font-medium">{cargo.cargoReference}</TableCell>
<TableCell className="max-w-xs truncate">{cargo.description}</TableCell>
<TableCell>{cargo.quantity}</TableCell>
<TableCell>{cargo.weight}</TableCell>
<TableCell>
<Badge className={getStatusColor(cargo.status)}>
{cargo.status}
</Badge>
</TableCell>
<TableCell>
{new Date(cargo.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(cargo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(cargo.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Form Dialog */}
<CargoFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
cargo={editingCargo}
onSuccess={handleFormSuccess}
/>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { useContainers } from '@/hooks/useContainers';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function ContainersPage() {
const { data: containers, isLoading } = useContainers();
if (isLoading) return <div>Loading containers...</div>;
return (
<Card>
<CardHeader><CardTitle>All Containers</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{containers?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.containerNumber}</TableCell>
<TableCell>{c.containerTypeId}</TableCell>
<TableCell>{c.wagonId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
export default function MyProfilePage() {
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<div id="signature">
<MySignatureCard />
</div>
</div>
);
}

View File

@@ -1,11 +1,186 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useState } from "react";
import {
AlertCircle,
Banknote,
FileText,
Train,
UserCheck,
Users,
} from "lucide-react";
import {
Alert,
Badge,
Button,
Container,
Paper,
Skeleton,
Stack,
Tabs,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
const TAB_ITEMS: Array<{
value: OverviewTabKey;
label: string;
icon: typeof FileText;
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
metricKey: string;
}> = [
{
value: "bookings",
label: "Bookings",
icon: FileText,
kpiKey: "bookings",
metricKey: "totalActive",
},
{
value: "billing",
label: "Billing",
icon: Banknote,
kpiKey: "billing",
metricKey: "successfulPaymentsMtd",
},
{
value: "operations",
label: "Operations",
icon: Train,
kpiKey: "operations",
metricKey: "trainsActive",
},
{
value: "customers",
label: "Customers",
icon: Users,
kpiKey: "customers",
metricKey: "totalCustomers",
},
{
value: "staff",
label: "Staff",
icon: UserCheck,
kpiKey: "staff",
metricKey: "activeEmployees",
},
];
function HeaderSkeleton() {
return (
<Stack gap="md">
<Skeleton height={48} radius="md" />
<Skeleton height={52} radius="lg" />
</Stack>
);
}
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
const queryClient = useQueryClient();
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
const handleRefresh = () => {
void refetch();
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
};
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0;
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
return group[tab.metricKey] ?? 0;
};
return (
<FeaturePlaceholder
title="Overview"
description="Track internal freight operations, monitor account administration, and review the latest backoffice activity from a single operational dashboard."
/>
<Container fluid px="md" py="md">
<Stack gap="lg">
{isLoading && !summary ? (
<HeaderSkeleton />
) : (
<OverviewPageHeader
range={range}
onRangeChange={setRange}
generatedAt={summary?.generatedAt}
onRefresh={handleRefresh}
isRefreshing={isFetching && !isLoading}
/>
)}
{isError && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="Unable to load dashboard summary"
variant="light"
>
<Stack gap="sm" align="flex-start">
<span>Check your connection and try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
Retry
</Button>
</Stack>
</Alert>
)}
<Tabs
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{TAB_ITEMS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
: undefined
}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{TAB_ITEMS.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />
</Paper>
</Stack>
</Container>
);
};

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { getCookie } from '@/auth/cookies';
function readToken(): string | null {
return getCookie('auth-token') ?? null;
}
function readRefreshToken(): string | null {
return getCookie('refresh-token') ?? null;
}
export default function UserManagementHostPage() {
const navigate = useNavigate();
const location = useLocation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const mountBase = (
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
).replace(/\/$/, '');
const moduleOrigin = window.location.origin;
const [iframeSrc] = useState(() => {
const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
return mountBase + (sub || '/') + location.search;
});
// ✅ Send token when iframe loads
const handleIframeLoad = () => {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (!token) {
console.warn('⚠️ No authentication token found');
return;
}
if (!target) {
console.warn('⚠️ No iframe reference');
return;
}
target.postMessage(
{
type: 'UM_AUTH_TOKEN',
token,
refreshToken,
},
moduleOrigin
);
console.log('✅ Token sent to iframe module');
};
// ✅ Listen for messages from iframe
useEffect(() => {
const onMessage = (event: MessageEvent) => {
// Security: Only accept from same origin
if (event.origin !== moduleOrigin) {
console.warn('🚫 Blocked message from different origin:', event.origin);
return;
}
const data = event.data as { type?: string; path?: string } | undefined;
if (!data) return;
// Handle auth request (if module asks for token again)
if (data.type === 'UM_REQUEST_AUTH') {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (token && target) {
target.postMessage(
{
type: 'UM_AUTH_TOKEN',
token,
refreshToken,
},
moduleOrigin
);
console.log('✅ Token resent to iframe (on request)');
}
return;
}
// Handle route synchronization
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
const target = '/dashboard/um' + data.path;
if (window.location.pathname + window.location.search !== target) {
navigate(target, { replace: true });
}
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [moduleOrigin, navigate]);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<iframe
ref={iframeRef}
title="User Management"
src={iframeSrc}
onLoad={handleIframeLoad}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
</div>
);
}

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug);
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const currentYardId = listFilterValues.currentYardId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (slug === "wagons" && search.trim()) {
filters.search = search.trim();
}
return filters;
}, [slug, listFilterValues, search]);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
const { create, update, remove } = useFleetMutations(slug);
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
setListFilterValues({});
}, [slug, setPagination]);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn || usesServerListFilters) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn, usesServerListFilters]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
);
const containerTypeOpts = (
containerTypes as Array<{ id: string; label?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.label ?? t.code ?? t.id }));
const cargoTypeOpts = (
cargoTypes as Array<{ id: string; cargoTypeName?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.cargoTypeName ?? t.code ?? t.id }));
const wagonOpts = (wagons as Array<{ id: string; wagonNumber: string }>).map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
const containerOpts = (containers as Array<{ id: string; containerNumber: string }>).map(
(c) => ({ value: c.id, label: c.containerNumber }),
);
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
registerFleetOptionLabels("containerTypeId", dynamicOptions.containerTypes);
registerFleetOptionLabels(
"cargoTypeId",
dynamicOptions.cargoTypes.filter((o) => o.value !== FLEET_SELECT_NONE),
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
if (!config) return [];
return config.formFields.map((field) => {
if (!field.dynamicOptions) return field;
const options = dynamicOptions[field.dynamicOptions] ?? [];
return { ...field, type: "select" as const, options };
});
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
if (usesServerListFilters) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
.toLowerCase()
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
(row.original as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
),
}));
base.push({
id: "actions",
header: "Actions",
size: 140,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
</div>
),
});
return base;
}, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
if (!config) {
return <Navigate to="/dashboard/locomotives" replace />;
}
const handleFormSubmit = async (values: Record<string, unknown>) => {
try {
if (editing && "id" in editing) {
await update.mutateAsync({ id: String(editing.id), data: values });
toast({ title: `${config.entityLabel} updated` });
} else {
await create.mutateAsync(values);
toast({ title: `${config.entityLabel} created` });
}
setFormOpen(false);
setEditing(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Save failed";
toast({ title: "Save failed", description: String(message), variant: "destructive" });
}
};
const handleRemove = async () => {
if (!removeTarget || !("id" in removeTarget)) return;
try {
await remove.mutateAsync(String(removeTarget.id));
toast({
title: config.removeSuccessMessage ?? `${config.entityLabel} removed`,
});
setRemoveTarget(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Remove failed";
toast({ title: "Remove failed", description: String(message), variant: "destructive" });
}
};
const itemLabel = config.label.toLowerCase();
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="xs" wrap="nowrap">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
size="sm"
radius="lg"
label={filter.label}
value={filter.value}
onChange={(v) => {
if (!v) return;
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={filter.data}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={statusFilterOptions}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
) : undefined
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRows}
status={tableStatus}
error={
isError
? {
message: "Failed to load data",
description: error instanceof Error ? error.message : "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: itemLabel } }}
/>
)}
/>
) : (
<FleetCardGrid
config={config}
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
onPaginationChange={setPagination}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
)}
</Stack>
</Card>
<FleetFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) setEditing(null);
}}
title={editing ? `Edit ${config.entityLabel}` : config.addLabel}
fields={formFields}
initialRecord={editing}
emptyValues={config.emptyValues}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
/>
<Modal
opened={Boolean(removeTarget)}
onClose={() => setRemoveTarget(null)}
title={<Text fw={600}>{config.removeActionLabel ?? "Delete"}</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
{config.removeConfirmMessage ??
`Are you sure you want to ${config.removeAction} this ${config.entityLabel.toLowerCase()}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRemoveTarget(null)}>
Cancel
</Button>
<Button color="red" loading={remove.isPending} onClick={handleRemove}>
{config.removeActionLabel ?? "Delete"}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FleetResourcePage;

View File

@@ -1,30 +1,45 @@
import { FormEvent, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
import { useToast } from '@/hooks/use-toast';
import type { RouteRecord, YardRef } from '@/services/routes.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import {
useCreateRoute,
useDeactivateRoute,
useRouteYards,
useRoutes,
useUpdateRoute,
} from "@/hooks/useRoutes";
import { useToast } from "@/hooks/use-toast";
import type { RouteRecord, YardRef } from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type RouteFormState = {
name: string;
milestones: string[];
};
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
@@ -33,22 +48,26 @@ const routeStops = (route: RouteRecord) =>
const normalizeRouteError = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
const data =
responseData && typeof responseData === "object"
? (responseData as Record<string, unknown>)
: undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage)
? rawMessage.join(', ')
? rawMessage.join(", ")
: rawMessage
? String(rawMessage)
: 'Save failed';
: "Save failed";
};
export default function RoutesPage() {
const [search, setSearch] = useState('');
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [viewing, setViewing] = useState<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(null);
const [form, setForm] = useState<RouteFormState>(emptyForm());
const { viewMode, setViewMode } = useFleetViewMode("routes");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast();
const routesQuery = useRoutes();
@@ -60,7 +79,6 @@ export default function RoutesPage() {
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
@@ -71,13 +89,18 @@ export default function RoutesPage() {
...routeStops(route),
]
.filter(Boolean)
.join(' ')
.join(" ")
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
const pagedRoutes = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
@@ -120,7 +143,7 @@ export default function RoutesPage() {
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
};
const removeMilestone = (index: number) => {
@@ -132,17 +155,15 @@ export default function RoutesPage() {
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
return;
}
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
toast({
title: 'Save failed',
description: 'Select at least an origin and destination yard',
variant: 'destructive',
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
@@ -153,29 +174,25 @@ export default function RoutesPage() {
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
if (editing) {
await updateMutation.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Route updated' });
toast({ title: "Route updated" });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Route created' });
toast({ title: "Route created" });
}
resetForm();
} catch (error) {
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
}
};
const handleDeactivate = async (route: RouteRecord) => {
if (!window.confirm('Deactivate this route?')) return;
try {
await deactivateMutation.mutateAsync(route.id);
toast({ title: 'Route deactivated' });
toast({ title: "Route deactivated" });
} catch {
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
}
};
@@ -185,195 +202,288 @@ export default function RoutesPage() {
const selectedByOthers = new Set(
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
);
return yardOptions.filter(
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
);
};
const tableStatus = routesQuery.isLoading
? "loading"
: routesQuery.isError
? "error"
: "success";
const columns = useMemo((): ColumnDef<RouteRecord>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
{
id: "origin",
header: "Origin",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.originYard),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.destinationYard),
},
{
id: "milestones",
header: "Milestones",
meta: { headerClassName, cellClassName },
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Tooltip label="View">
<ActionIcon variant="subtle" color="gray" onClick={() => setViewing(row.original)}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Deactivate">
<ActionIcon
variant="subtle"
color="red"
disabled={!row.original.isActive || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}, [deactivateMutation.isPending]);
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add Route
</Button>
</div>
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder="Search routes"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Milestones</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell>{route.name}</TableCell>
<TableCell>{yardLabel(route.originYard)}</TableCell>
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
<Eye className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
<Edit className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(route)}
title="Deactivate"
disabled={!route.isActive || deactivateMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
No routes found.
</TableCell>
</TableRow>
) : null}
{routesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="route-name">Name</Label>
<Input
id="route-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRoutes}
status={tableStatus}
emptyMessage="No routes found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "routes" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{tableStatus === "loading" ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
Loading
</Text>
) : !pagedRoutes.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No routes found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{pagedRoutes.map((route) => (
<Card key={route.id} radius="lg" padding="lg" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{route.name}</Text>
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
{route.isActive ? "Active" : "Inactive"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{yardLabel(route.originYard)} {yardLabel(route.destinationYard)}
</Text>
<Text size="xs" c="dimmed">
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
</Text>
<Group gap={6} justify="flex-end">
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View
</Button>
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
itemLabel="routes"
onPaginationChange={setPagination}
/>
</div>
</Stack>
)}
</Stack>
</Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stops</Label>
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
<Plus className="size-4" />
Add next milestone
</Button>
</div>
{form.milestones.map((yardId, index) => {
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
const availableOptions = availableOptionsForIndex(index);
return (
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
<p className="text-sm font-medium">{role}</p>
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
<SelectTrigger>
<SelectValue placeholder="Select yard" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMilestone(index)}
disabled={form.milestones.length <= 2}
title="Remove stop"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetForm}>
<Modal
opened={formOpen}
onClose={resetForm}
title={<Text fw={600}>{editing ? "Edit Route" : "Add Route"}</Text>}
size="lg"
radius="lg"
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>
Stops
</Text>
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
Add milestone
</Button>
</Group>
{form.milestones.map((yardId, index) => {
const role =
index === 0
? "Origin"
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
<Text w={100} size="sm" fw={500}>
{role}
</Text>
<Select
style={{ flex: 1 }}
data={availableOptionsForIndex(index)}
value={yardId || null}
onChange={(value) => value && setMilestone(index, value)}
placeholder="Select yard"
searchable
/>
<ActionIcon
variant="subtle"
color="red"
disabled={form.milestones.length <= 2}
onClick={() => removeMilestone(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
);
})}
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
<Button color="green" type="submit" loading={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Group>
</Stack>
</form>
</Modal>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Route details</DialogTitle>
</DialogHeader>
{viewing ? (
<div className="space-y-3 text-sm">
<div>
<p className="font-medium">Name</p>
<p className="text-muted-foreground">{viewing.name}</p>
</div>
<div>
<p className="font-medium">Status</p>
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
</div>
<div>
<p className="font-medium">Stops</p>
<div className="mt-2 space-y-2">
{routeStops(viewing).map((stop, index, stops) => (
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
{' '}
{stop}
</div>
))}
</div>
</div>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}
title={<Text fw={600}>Route details</Text>}
radius="lg"
centered
>
{viewing ? (
<Stack gap="sm">
<div>
<Text size="sm" fw={500}>
Name
</Text>
<Text size="sm" c="dimmed">
{viewing.name}
</Text>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
<div>
<Text size="sm" fw={500}>
Status
</Text>
<Text size="sm" c="dimmed">
{viewing.isActive ? "Active" : "Inactive"}
</Text>
</div>
<div>
<Text size="sm" fw={500}>
Stops
</Text>
<Stack gap={6} mt={6}>
{routeStops(viewing).map((stop, index, stops) => (
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
{index === 0
? "Origin"
: index === stops.length - 1
? "Destination"
: `Milestone ${index}`}
: {stop}
</Text>
))}
</Stack>
</div>
</Stack>
) : null}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,359 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
| "locomotives"
| "trains"
| "wagons"
| "containers"
| "cargoes";
export const FLEET_SELECT_NONE = "__none__";
import type { WagonListFilters } from "@/services/wagon.service";
export type FleetListFilters = WagonListFilters;
export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers"
| "yards";
export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
}
export interface FleetListFilterDef {
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
dynamicOptions?: FleetDynamicOptions;
}
export interface FleetResourceConfig {
slug: FleetResourceSlug;
label: string;
subtitle: string;
basePath: string;
addLabel: string;
entityLabel: string;
searchPlaceholder: string;
supportsSearch: boolean;
/** Server-side list filters (e.g. wagon status / readiness). */
listFilters?: FleetListFilterDef[];
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
removeAction: "delete" | "decommission";
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
detailPath?: string;
cardTitleKey?: string;
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
locomotives: "/dashboard/locomotives",
trains: "/dashboard/trains",
wagons: "/dashboard/wagons",
containers: "/dashboard/containers",
cargoes: "/dashboard/cargoes",
};
const LOCOMOTIVE_TYPE_OPTIONS = [
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
];
const LOCOMOTIVE_STATUS_OPTIONS = [
{ label: "Available", value: "AVAILABLE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Assigned", value: "ASSIGNED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
addLabel: "Add Locomotive",
entityLabel: "Locomotive",
searchPlaceholder: "Search locomotives…",
supportsSearch: true,
removeAction: "decommission",
removeActionLabel: "Decommission",
removeConfirmMessage: "Decommission this locomotive?",
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardSubtitleKey: "currentYard",
searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
},
},
{
slug: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
addLabel: "Add Train",
entityLabel: "Train",
searchPlaceholder: "Search trains…",
supportsSearch: true,
removeAction: "delete",
detailPath: "/dashboard/trains/:id",
cardTitleKey: "trainName",
cardCodeKey: "code",
cardSubtitleKey: "trainNumber",
searchKeys: ["code", "trainNumber", "trainName", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "trainNumber", header: "Number", accessorKey: "trainNumber" },
{ id: "trainName", header: "Name", accessorKey: "trainName" },
{ id: "capacityTons", header: "Capacity (tons)", accessorKey: "capacityTons", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "trainNumber", label: "Train number", type: "text" },
{ name: "trainName", label: "Train name", type: "text" },
{ name: "locomotiveNumber", label: "Locomotive number", type: "text" },
{ name: "status", label: "Status", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
{ name: "remarks", label: "Remarks", type: "textarea" },
],
emptyValues: {
code: "",
capacityTons: 0,
trainNumber: "",
trainName: "",
locomotiveNumber: "",
status: "AVAILABLE",
notes: "",
remarks: "",
},
},
{
slug: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",
addLabel: "Add Wagon",
entityLabel: "Wagon",
searchPlaceholder: "Search wagons…",
supportsSearch: true,
removeAction: "delete",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: WAGON_STATUS_OPTIONS,
},
{
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",
},
},
{
slug: "containers",
label: "Containers",
subtitle: "Manage container master data and wagon assignments",
basePath: "/dashboard/containers",
addLabel: "Add Container",
entityLabel: "Container",
searchPlaceholder: "Search containers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "containerNumber",
cardSubtitleKey: "status",
searchKeys: ["containerNumber", "containerTypeId", "wagonId", "status"],
columns: [
{ id: "containerNumber", header: "Number", accessorKey: "containerNumber", format: "code" },
{ id: "containerTypeId", header: "Type", accessorKey: "containerTypeId", format: "entityLabel" },
{ id: "wagonId", header: "Wagon", accessorKey: "wagonId", format: "entityLabel" },
{ id: "maxGrossWeight", header: "Max gross", accessorKey: "maxGrossWeight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "containerNumber", label: "Container number", type: "text", required: true },
{ name: "containerTypeId", label: "Container type", type: "select", required: true, dynamicOptions: "containerTypes" },
{ name: "wagonId", label: "Wagon", type: "select", dynamicOptions: "wagons", noneOption: true },
{ name: "position", label: "Position", type: "number" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxGrossWeight", label: "Max gross weight", type: "number", required: true },
{ name: "sealNumber", label: "Seal number", type: "text" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
containerNumber: "",
containerTypeId: "",
wagonId: "",
position: "",
tareWeight: 0,
maxGrossWeight: 0,
sealNumber: "",
status: "AVAILABLE",
},
},
{
slug: "cargoes",
label: "Cargoes",
subtitle: "Manage cargo records linked to containers",
basePath: "/dashboard/cargoes",
addLabel: "Add Cargo",
entityLabel: "Cargo",
searchPlaceholder: "Search cargoes…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "cargoReference",
cardSubtitleKey: "status",
searchKeys: ["cargoReference", "description", "containerId", "status"],
columns: [
{ id: "cargoReference", header: "Reference", accessorKey: "cargoReference", format: "code" },
{ id: "cargoTypeId", header: "Cargo type", accessorKey: "cargoTypeId", format: "entityLabel" },
{ id: "containerId", header: "Container", accessorKey: "containerId", format: "entityLabel" },
{ id: "quantity", header: "Quantity", accessorKey: "quantity", format: "number" },
{ id: "weight", header: "Weight", accessorKey: "weight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "cargoReference", label: "Cargo reference", type: "text", required: true },
{ name: "shipmentId", label: "Shipment ID", type: "text", required: true },
{ name: "containerId", label: "Container", type: "select", required: true, dynamicOptions: "containers" },
{ name: "cargoTypeId", label: "Cargo type", type: "select", dynamicOptions: "cargoTypes", noneOption: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "quantity", label: "Quantity", type: "number", required: true },
{ name: "weight", label: "Weight", type: "number", required: true },
{ name: "volume", label: "Volume", type: "number" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
cargoReference: "",
shipmentId: "",
containerId: "",
cargoTypeId: "",
description: "",
quantity: 0,
weight: 0,
volume: "",
status: "PENDING",
},
},
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>
FLEET_RESOURCES.find((resource) => resource.slug === slug);
export const getFleetSlugFromPath = (pathname: string): FleetResourceSlug | undefined => {
const normalized = pathname.toLowerCase();
return FLEET_RESOURCES.find((resource) => normalized === resource.basePath.toLowerCase())?.slug;
};
export const getFleetRouteMeta = () =>
FLEET_RESOURCES.map((resource) => ({
prefix: resource.basePath,
meta: { title: resource.label, subtitle: resource.subtitle },
}));

View File

@@ -0,0 +1,367 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Box,
Card,
Container,
Group,
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
Loader2,
RotateCcw,
Search,
X,
XCircle,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
PaymentRow,
} from "@/services/payments.service";
import { cn } from "@/lib/utils";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined },
{ key: "success", label: "Success", statuses: "success" },
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
{ key: "refunded", label: "Refunded", statuses: "refunded" },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "telebirr", label: "Telebirr" },
{ value: "waafi", label: "Waafi" },
{ value: "cbe-birr", label: "CBE Birr" },
{ value: "ebirr", label: "E-Birr" },
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
];
const STATUS_COLORS: Record<string, string> = {
success: "green",
processing: "yellow",
"action-required": "yellow",
failed: "red",
canceled: "gray",
refunded: "indigo",
};
function StatCard({
icon: Icon,
label,
value,
accent,
}: {
icon: LucideIcon;
label: string;
value: string | number;
accent: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
const [method, setMethod] = useState<string | null>(null);
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
const filter = useMemo(
() => ({
search: query.trim() || undefined,
status: statuses,
method: method ?? undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = usePaymentList(filter);
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const columns: ColumnDef<PaymentRow>[] = [
{
id: "order",
header: () => <span className={tableHeader}>Order</span>,
cell: ({ row }) => (
<div className="min-w-0 py-1">
<p className="truncate font-medium text-foreground">
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
</p>
</div>
),
},
{
id: "amount",
header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: "method",
header: () => <span className={tableHeader}>Method</span>,
cell: ({ row }) => (
<Badge variant="light" radius="sm">
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
row.original.method}
</Badge>
),
},
{
id: "status",
header: () => <span className={tableHeader}>Status</span>,
cell: ({ row }) => (
<Badge
color={STATUS_COLORS[row.original.status] ?? "gray"}
variant="light"
radius="sm"
tt="capitalize"
>
{row.original.status.replace(/-/g, " ")}
</Badge>
),
},
{
id: "date",
header: () => <span className={tableHeader}>Date</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.paidAt ?? row.original.createdAt)}
</span>
),
},
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
}
accent="teal"
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
/>
</Group>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{STATUS_TABS.map((t) => (
<Tabs.Tab key={t.key} value={t.key}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
</Stack>
</Container>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
@@ -8,7 +8,10 @@ import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -29,6 +32,8 @@ import {
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
@@ -65,6 +70,7 @@ const RuleEngineResourcePage = () => {
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [chainOpen, setChainOpen] = useState(false);
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
@@ -81,10 +87,27 @@ const RuleEngineResourcePage = () => {
search: config?.supportsSearch ? search.trim() || undefined : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(config?.orderConfig
? {
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
: {}),
}),
[config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
[
config?.orderConfig,
config?.supportsSearch,
search,
pagination.pageIndex,
pagination.pageSize,
],
);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
listParams,
@@ -93,6 +116,14 @@ const RuleEngineResourcePage = () => {
const { create, update, remove } = useRuleEngineMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { reorder, moveOrder } = useRuleEngineOrderMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules",
@@ -149,25 +180,24 @@ const RuleEngineResourcePage = () => {
const rows = data?.data ?? [];
const meta = data?.meta;
const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length;
const filteredRows = useMemo(() => {
if (config?.supportsSearch || !search.trim()) return rows;
const q = search.trim().toLowerCase();
return rows.filter((row) =>
JSON.stringify(row).toLowerCase().includes(q),
);
}, [rows, search, config?.supportsSearch]);
const paginationState = useMemo(
() => ({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: meta?.total ?? filteredRows.length,
}),
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
return createPositionList.data
.filter((row) => row.id)
.map((row) => ({
label: getOrderItemLabel(row, config.slug),
value: String(row.id),
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
@@ -176,6 +206,13 @@ const RuleEngineResourcePage = () => {
[approve],
);
const handleMoveOrder = useCallback(
(id: string, direction: "up" | "down") => {
moveOrder.mutate({ id, direction });
},
[moveOrder],
);
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
if (!config) return [];
@@ -192,36 +229,47 @@ const RuleEngineResourcePage = () => {
base.push({
id: "actions",
header: "Actions",
size: 140,
minSize: 120,
size: config.orderConfig ? 200 : 140,
minSize: config.orderConfig ? 180 : 120,
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<RuleEngineRecordActions
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{config.orderConfig && canManage ? (
<RuleEngineOrderControls
record={row.original}
orderConfig={config.orderConfig}
totalCount={totalCount}
disabled={moveOrder.isPending}
onMove={handleMoveOrder}
/>
) : null}
<RuleEngineRecordActions
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
</Group>
</div>
),
});
return base;
}, [canManage, config, submit, handleApproveRate]);
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -248,9 +296,17 @@ const RuleEngineResourcePage = () => {
};
const handleFormSubmit = (values: Record<string, unknown>) => {
let payload = values;
if (config.slug === "rates") {
payload = { ...values, currency: "USD" };
} else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
}
if (editing?.id) {
update.mutate(
{ id: editing.id, payload: values },
{ id: editing.id, payload },
{
onSuccess: () => {
setFormOpen(false);
@@ -259,7 +315,7 @@ const RuleEngineResourcePage = () => {
},
);
} else {
create.mutate(values, {
create.mutate(payload, {
onSuccess: () => {
setFormOpen(false);
setEditing(null);
@@ -276,13 +332,21 @@ const RuleEngineResourcePage = () => {
<Stack gap="md">
<RuleEngineToolbar
search={search}
onSearchChange={(v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
onSearchChange={
config.supportsSearch
? (v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onAdd={canManage ? openCreate : undefined}
addLabel={`Add ${config.label.replace(/s$/, "")}`}
onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
@@ -290,7 +354,7 @@ const RuleEngineResourcePage = () => {
{viewMode === "table" ? (
<DataTable
columns={columns}
data={filteredRows}
data={rows}
status={tableStatus}
error={
isError
@@ -302,7 +366,12 @@ const RuleEngineResourcePage = () => {
: undefined
}
emptyMessage={`No ${itemLabel} found.`}
pagination={paginationState}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount,
}}
tableOptions={{
manualPagination: true,
pageCount,
@@ -328,11 +397,14 @@ const RuleEngineResourcePage = () => {
) : (
<RuleEngineCardGrid
config={config}
rows={filteredRows}
rows={rows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found.`}
itemLabel={itemLabel}
pagination={paginationState}
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
onPaginationChange={setPagination}
readOnly={!canManage}
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
@@ -363,9 +435,27 @@ const RuleEngineResourcePage = () => {
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}
onSubmit={handleFormSubmit}
/>
{config.orderConfig ? (
<ManageRuleEngineOrderDialog
open={orderDialogOpen}
onOpenChange={setOrderDialogOpen}
config={config}
items={orderListData?.data ?? []}
isLoading={orderListLoading}
isSaving={reorder.isPending}
onSave={(payload) => {
reorder.mutate(payload, {
onSuccess: () => setOrderDialogOpen(false),
});
}}
/>
) : null}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}

View File

@@ -34,6 +34,14 @@ export interface FormFieldDef {
optional?: boolean;
options?: { label: string; value: string }[];
placeholder?: string;
/** Hide this field when another field currently equals one of these values. */
hideWhen?: { field: string; equals: string[] };
}
export interface RuleEngineOrderConfig {
field: "displayOrder" | "stepOrder";
scopeField?: "requiresDirectorApproval";
label: string;
}
export interface RuleEngineResourceConfig {
@@ -45,6 +53,7 @@ export interface RuleEngineResourceConfig {
columns: ResourceColumn[];
formFields: FormFieldDef[];
supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig;
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
@@ -104,8 +113,13 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
}));
const CURRENCIES = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
];
const PRIORITY_CONFIG_TYPES = [
{ label: "Wagon count", value: "WAGON" },
{ label: "Payment currency", value: "CURRENCY" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
@@ -130,6 +144,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Manage freight cargo classification and approval rules",
searchPlaceholder: "Search cargo types by name or code...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
@@ -154,7 +169,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -163,9 +177,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Configure container sizes and wagon capacity",
searchPlaceholder: "Search container types...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
activeColumn,
@@ -177,7 +193,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isReefer", label: "Reefer", type: "boolean" },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -221,29 +236,41 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
},
{
slug: "priority-rules",
slug: "priority-configs",
label: "Priority Rules",
category: "rules",
subtitle: "Booking priority scoring rules",
subtitle: "Wagon-count and payment-currency scoring rules",
searchPlaceholder: "Search priority rules...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "score", header: "Score", accessorKey: "score", format: "number" },
{ id: "conditionCurrency", header: "Currency", accessorKey: "conditionCurrency" },
{ id: "type", header: "Type", accessorKey: "type" },
{ id: "currency", header: "Currency", accessorKey: "currency" },
{ id: "minWagonCount", header: "Min wagons", accessorKey: "minWagonCount", format: "number" },
{ id: "maxWagonCount", header: "Max wagons", accessorKey: "maxWagonCount", format: "number" },
{ id: "scorePoints", header: "Points", accessorKey: "scorePoints", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
{
name: "conditionCurrency",
label: "Condition currency",
name: "type",
label: "Type",
type: "select",
required: true,
options: PRIORITY_CONFIG_TYPES,
placeholder: "Wagon count or payment currency",
},
{
name: "currency",
label: "Currency",
type: "select",
optional: true,
options: [{ label: "Any", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Any currency (optional)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -254,9 +281,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Freight service offerings and booking options",
searchPlaceholder: "Search service types...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
@@ -269,7 +298,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -350,6 +378,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Terminal and yard locations",
searchPlaceholder: "Search yards...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
@@ -361,7 +390,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -421,7 +449,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
options: TRADE_DIRECTIONS,
},
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
@@ -436,6 +463,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "requiredRole",
subtitle: "Multi-step booking approval chain",
searchPlaceholder: "Search approval rules...",
orderConfig: {
field: "stepOrder",
scopeField: "requiresDirectorApproval",
label: "Step order",
},
columns: [
{
id: "requiresDirectorApproval",
@@ -450,7 +482,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
{ name: "stepOrder", label: "Step order", type: "number", required: true },
{
name: "requiredRole",
label: "Required role",
@@ -491,7 +522,7 @@ export const getCategorySidebarChildren = (
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-rules";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-configs";
/** @deprecated Use DEFAULT_CONFIGURATION_SLUG */
export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;

View File

@@ -0,0 +1,728 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert,
Box,
Button,
Card,
Container,
Group,
Paper,
RingProgress,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CalendarDays,
Inbox,
Package,
Ruler,
Train,
TrainFront,
Weight,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const fmtScheduleDate = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso)) + " EAT"
: "No date";
const splitDate = (iso: string | null) => {
if (!iso) return { day: "—", time: "" };
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
return {
day: new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "Africa/Addis_Ababa",
}).format(date),
time:
new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(date) + " EAT",
};
};
/** Capacity ring color: gold normally, red once over capacity. */
function ringColor(pct: number) {
if (pct >= 100) return "#fa5252";
return "#F2A516";
}
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */
function CapacityRing({
pct,
label,
current,
max,
}: {
pct: number;
label: string;
current: string;
max: string;
}) {
const clamped = Math.min(100, Math.max(0, pct));
const color = ringColor(pct);
return (
<Stack gap={4} align="center" style={{ flex: 1 }}>
<RingProgress
size={104}
thickness={9}
roundCaps
sections={[{ value: clamped, color }]}
rootColor="var(--mantine-color-gray-1)"
label={
<Stack gap={0} align="center">
<Text ta="center" size="lg" fw={800} lh={1} style={{ color }}>
{Math.round(pct)}%
</Text>
<Text ta="center" size="9px" c="dimmed" fw={600}>
{label}
</Text>
</Stack>
}
/>
<Stack gap={0} align="center">
<Text size="xs" fw={700} c="dark.4">
{current}
</Text>
<Text size="xs" c="dimmed">
of {max}
</Text>
</Stack>
</Stack>
);
}
/** Small percent chip used in the table's capacity column. */
function CapacityChip({
icon: Icon,
pct,
text,
}: {
icon: typeof Weight;
pct: number | null;
text: string;
}) {
const over = pct != null && pct >= 100;
return (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: over ? "var(--mantine-color-red-0)" : "var(--mantine-color-gray-1)",
border: `1px solid ${over ? "var(--mantine-color-red-2)" : "var(--mantine-color-gray-2)"}`,
}}
>
<Icon size={12} color={over ? "var(--mantine-color-red-6)" : "var(--mantine-color-gray-6)"} />
<Text size="xs" fw={700} c={over ? "red.7" : "gray.7"} lh={1.2}>
{pct != null ? `${Math.round(pct)}%` : "—"}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{text}
</Text>
</Group>
);
}
function weightPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
: null;
}
function lengthPctOf(s: BatchBoardSchedule) {
return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
: null;
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const lengthPct = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const totalBookings = totalBookingCount(counts);
return (
<Paper
className="bb-card"
radius="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
overflow: "hidden",
cursor: "pointer",
display: "flex",
flexDirection: "column",
}}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
<Stack gap="md" p="lg" style={{ flex: 1 }}>
{/* header */}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<Train size={22} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={800} size="md" truncate style={{ letterSpacing: 0.2 }}>
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.8 }}
>
Freight schedule · {schedule.status}
</Text>
</Box>
</Group>
<WindowStatusPill status={schedule.bookingWindowStatus} />
</Group>
<RouteCorridor
origin={schedule.origin}
destination={schedule.destination}
variant="compact"
/>
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{fmtScheduleDate(schedule.scheduleDate)}
</HeroChip>
{locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
{locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
</HeroChip>
) : null}
</Group>
{!locomotive ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={15} />}
py={6}
styles={{ message: { fontSize: 12 } }}
>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
{/* capacity: weight + length rings + wagons (numbers preserved) */}
<Box
py="sm"
px="xs"
style={{
borderRadius: 14,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<Group justify="space-around" align="center" wrap="nowrap" gap="xs">
{weightPct != null ? (
<CapacityRing
pct={weightPct}
label="WEIGHT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
{lengthPct != null ? (
<CapacityRing
pct={lengthPct}
label="LENGTH"
current={fmtMeters(capacity.allocatedLengthMeters)}
max={fmtMeters(capacity.maxLengthMeters ?? 0)}
/>
) : null}
</Group>
</Box>
{/* booking pipeline */}
<Box>
<Group justify="space-between" mb={6}>
<Group gap={5} wrap="nowrap">
<Package size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Booking pipeline
</Text>
</Group>
<Text size="xs" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={counts} />
</Box>
</Stack>
{/* CTA */}
<Box px="lg" pb="lg">
<Button
fullWidth
radius="md"
variant="gradient"
gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
rightSection={<ArrowRight size={16} className="bb-arrow" />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
}}
>
View batch windows
</Button>
</Box>
</Paper>
);
}
function CardSkeleton() {
return (
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
<Stack gap="md" p="lg">
<Group justify="space-between">
<Group gap="sm">
<Skeleton height={44} width={44} radius="md" />
<Skeleton height={32} width={120} />
</Group>
<Skeleton height={22} width={90} radius="xl" />
</Group>
<Skeleton height={120} radius="md" />
<Skeleton height={10} radius="xl" />
<Skeleton height={36} radius="md" />
</Stack>
</Paper>
);
}
export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isFetching, refetch } = useBatchBoard();
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [windowFilter, setWindowFilter] = useState("ALL");
const schedules = data ?? [];
const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0);
const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0);
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;
return [
{
id: "train",
header: "Train / Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="#F2A516">
<Train size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={700} lh={1.2} truncate>
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
</Group>
),
},
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-orange-0)",
color: "#B26C09",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "window",
header: "Window",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <WindowStatusPill status={row.original.bookingWindowStatus} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive ? (
<Group gap={6} wrap="nowrap">
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.locomotive.code}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{fmtTons(row.original.locomotive.maxPullWeightTons)} pull
</Text>
</Stack>
</Group>
) : (
<Text size="xs" c="red.6" fw={600}>
No loco
</Text>
),
},
{
id: "capacity",
header: "Capacity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
</Group>
</Group>
),
},
{
id: "bookings",
header: "Bookings",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const total = totalBookingCount(row.original.counts);
return (
<Stack gap={4} style={{ minWidth: 130 }}>
<Text size="sm" fw={700} c="dark.4" lh={1.2}>
{total} booking{total === 1 ? "" : "s"}
</Text>
<BookingPipeline counts={row.original.counts} size={10} />
</Stack>
);
},
},
{
id: "actions",
header: "",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Button>
</Group>
),
},
];
}, [navigate]);
const tableStatus = isLoading ? "loading" : "success";
return (
<Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
<StatTile
icon={Train}
label="Active schedules"
value={isLoading ? "…" : schedules.length}
hint="on the board right now"
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarDays}
label="Open windows"
value={isLoading ? "…" : summary.openWindows}
hint="accepting bookings"
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
<StatTile
icon={Package}
label="Bookings in play"
value={isLoading ? "…" : summary.totalBookings}
hint={`${summary.totalWagons} wagons allocated`}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={
schedules.length
? Math.min(100, Math.round((summary.openWindows / schedules.length) * 100))
: 0
}
/>
</SimpleGrid>
<Card
radius="lg"
padding={0}
withBorder
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
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)" } }}
/>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No active schedules"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "schedules" } }}
/>
)}
/>
) : isLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : filtered.length === 0 ? (
<Paper radius="lg" p={48} m="md" bg="gray.0">
<Stack align="center" gap="sm">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
</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.
</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>
)}
</Stack>
</Card>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
color="gray"
size="xs"
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Container>
);
}

View File

@@ -0,0 +1,331 @@
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Flag,
MapPin,
Navigation,
Train,
} from "lucide-react";
import {
Badge,
Box,
Button,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
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;
}
return fallback;
};
function formatDateTime(iso?: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** Compact icon + label + value cell used in the header meta strip. */
function MetaStat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
{label}
</Text>
<Text size="sm" fw={700} c="dark.5" truncate>
{value}
</Text>
</Stack>
</Group>
);
}
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const trackQuery = useTrainTrack(scheduleId);
const { recordCheckpoint } = useScheduleMutations(scheduleId);
if (trackQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" color="green" />
</Group>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
);
}
const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const clampedPct = Math.min(100, Math.max(0, progressPct));
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } },
{
onSuccess: () => {
toast({
title: isFinal
? "Train arrived — assets freed, moved to destination yard"
: "Checkpoint logged",
});
},
onError: (err) =>
toast({
title: "Could not log checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
return (
<Stack gap="md" px={{ base: "xs", sm: 0 }} py="md" maw={1080} mx="auto" w="100%">
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedule
</Button>
{/* Header */}
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 48,
height: 48,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
flexShrink: 0,
}}
>
<Navigation size={24} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800}>
Train tracking
</Title>
{track.trainNumber ? (
<Badge variant="light" color="green" radius="sm">
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge variant="light" color="gray" radius="sm">
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={360}>
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
</Box>
</Stack>
</Group>
<StatusPill status={track.status} />
</Group>
{/* Journey progress */}
<Box>
<Group justify="space-between" mb={6}>
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Journey progress
</Text>
<Text size="xs" fw={700} c="green.8">
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
</Text>
</Group>
<Progress
value={clampedPct}
size="lg"
radius="xl"
color="green"
striped={track.status === "DISPATCHED"}
animated={track.status === "DISPATCHED"}
/>
</Box>
{/* Meta strip */}
<Group justify="space-between" wrap="wrap" gap="lg">
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<MetaStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<MetaStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<MetaStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Stack>
</Paper>
{/* Corridor */}
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<Navigation size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Route corridor
</Text>
<Text size="xs" c="dimmed">
{canLog
? "Log the train passing each station; the final station marks arrival."
: track.status === "ARRIVED"
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."}
</Text>
</Stack>
</Group>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
}
onLogCheckpoint={handleLog}
/>
</Stack>
</Paper>
{/* Checkpoint log */}
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group gap="sm" align="center" wrap="nowrap" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<CheckCircle2 size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Checkpoint log
</Text>
<Text size="xs" c="dimmed">
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
</Text>
</Stack>
</Group>
{track.checkpoints.length === 0 ? (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<MapPin size={20} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
No checkpoints yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={300}>
Each station the train passes will be logged here with its timestamp.
</Text>
</Stack>
) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
title={
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
>
{cp.kind}
</Badge>
</Group>
}
>
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
</Text>
) : null}
</Timeline.Item>
))}
</Timeline>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,933 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Container as ContainerIcon,
Eye,
LayoutGrid,
Navigation,
Package,
Route as RouteIcon,
Send,
Train,
Weight,
} from "lucide-react";
import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
RingProgress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import {
autoFillPlacements,
mergePlacementsWithSaved,
placementsFromScheduleWagons,
validateLocalPlacements,
} from "@/components/trainScheduling/containerPlacement.util";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useEligibleBookings,
useScheduleDetail,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
FreightType,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
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;
};
export default function TrainScheduleV2DetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useScheduleDetail(scheduleId);
const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const eligibleFilters = useMemo(
() =>
schedule
? {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
// Only this schedule's own bookings are eligible — same rule as the auto batch.
trainScheduleId: scheduleId,
}
: undefined,
[schedule, scheduleId],
);
const eligibleFreightType =
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
const eligibleQuery = useEligibleBookings(
eligibleFilters,
Boolean(schedule),
eligibleFreightType,
);
const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId);
const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id),
[schedule?.bookings],
);
const allSelectedIds = useMemo(() => {
const merged = new Set([...assignedIds, ...selectedBookingIds]);
return [...merged];
}, [assignedIds, selectedBookingIds]);
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];
// Map each slot to its reserved physical wagon number (from the wagon table) so the
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
const physicalBySeq = new Map(
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
);
if (previewResult?.wagonPlan?.length) {
return previewResult.wagonPlan.map((slot) => ({
...slot,
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
}));
}
if (savedWagons.length) return savedWagons;
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null;
if (!allSelectedIds.length) {
if (!options?.silent) {
toast({ title: "Select at least one booking", variant: "destructive" });
}
return null;
}
const originStationId = schedule.originStation?.id;
const destinationStationId = schedule.destinationStation?.id;
if (!originStationId || !destinationStationId) {
if (!options?.silent) {
toast({ title: "Schedule missing origin or destination", variant: "destructive" });
}
return null;
}
try {
const result = await preview.mutateAsync({
freightType,
payload: {
bookingIds: allSelectedIds,
scheduleDate: schedule.scheduledDepartureDate,
originStationId,
destinationStationId,
targetScheduleId: scheduleId,
},
});
setPreviewResult(result);
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
const autoFilled = autoFillPlacements(
result.containerUnits,
result.containerSlotSequenceNos,
);
const saved = schedule.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [];
setContainerPlacements(
saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled,
);
} else {
setContainerPlacements([]);
}
if (!options?.silent) {
if (!result.valid) {
toast({ title: "Preview has violations", variant: "destructive" });
} else if (options?.advanceStep !== false) {
setActiveStep(1);
}
}
return result;
} catch (err) {
if (!options?.silent) {
toast({
title: "Preview failed",
description: parseError(err, "Could not preview"),
variant: "destructive",
});
}
return null;
}
},
[allSelectedIds, freightType, preview, schedule, scheduleId, toast],
);
useEffect(() => {
if (!schedule || !scheduleId || autoPreviewedRef.current) return;
if (!assignedIds.length) return;
autoPreviewedRef.current = true;
void runPreview({ silent: true, advanceStep: false });
}, [assignedIds.length, runPreview, schedule, scheduleId]);
const savedPlacementsFromSchedule = useMemo(
() =>
schedule?.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [],
[schedule?.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]);
if (detailQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!schedule || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Schedule not found
</Text>
);
}
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const handleAssign = async () => {
if (!allSelectedIds.length) return;
if (hasContainerStep) {
const issues = validateLocalPlacements(containerUnits, containerPlacements);
if (issues.length) {
toast({
title: "Complete container assignments",
description: issues.join(", "),
variant: "destructive",
});
return;
}
}
try {
const result = await assign.mutateAsync({
id: scheduleId,
freightType,
payload: {
bookingIds: allSelectedIds,
forceAssign,
containerPlacements: hasContainerStep ? containerPlacements : undefined,
},
});
toast({ title: "Bookings assigned — wagons auto-pinned" });
const refreshed = await detailQuery.refetch();
const saved = refreshed.data?.trainSet?.wagons
? placementsFromScheduleWagons(refreshed.data.trainSet.wagons)
: [];
if (saved.length) {
setContainerPlacements(saved);
}
autoPreviewedRef.current = false;
setActiveStep(finalizeStep);
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 handleUnassign = async (bookingId: string) => {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId });
toast({ title: "Booking unassigned" });
setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId));
setPreviewResult(null);
autoPreviewedRef.current = false;
} catch (err) {
toast({
title: "Unassign failed",
description: parseError(err, "Could not unassign"),
variant: "destructive",
});
}
};
const containerComplete =
hasContainerStep &&
containerUnits.length > 0 &&
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
schedule.status,
);
const stepsMeta = [
{
key: "bookings",
icon: Package,
title: "Bookings",
subtitle: "Select cargo & preview the plan",
complete: Boolean(previewResult) || assignedIds.length > 0,
},
{
key: "wagon",
icon: LayoutGrid,
title: "Wagon plan",
subtitle: "Review generated allocations",
complete: displayWagonPlan.length > 0,
},
...(hasContainerStep
? [
{
key: "container",
icon: ContainerIcon,
title: "Containers",
subtitle: "Map units to wagon slots",
complete: containerComplete,
},
]
: []),
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
complete: finalizeComplete,
},
];
const completedCount = stepsMeta.filter((s) => s.complete).length;
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
const renderStepRightSlot = (key: string) => {
if (key === "bookings") {
if (previewResult) {
return (
<Badge
variant="light"
color={previewResult.valid ? "green" : "red"}
radius="sm"
>
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allSelectedIds.length ? (
<Badge variant="light" color="green" radius="sm">
{allSelectedIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge
variant="light"
color={containerComplete ? "green" : "yellow"}
radius="sm"
>
{containerUnits.length} units
</Badge>
);
}
if (key === "finalize") {
return <StatusPill status={schedule.status} />;
}
return null;
};
const renderStepBody = (key: string) => {
if (key === "bookings") {
return (
<Stack gap="md">
<ScheduleBookingsStep
assignedBookings={(schedule.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={allSelectedIds}
onSelectionChange={(ids) => {
const assigned = new Set(assignedIds);
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
}}
assignedIds={assignedIds}
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
/>
{canEditBookings ? (
<Group
align="center"
justify="space-between"
wrap="wrap"
gap="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Checkbox
label="Force assign (bypass hold / overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
size="sm"
/>
<Button
color="green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
onClick={() => void runPreview()}
>
Preview plan
</Button>
</Group>
) : null}
{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>
);
}
if (key === "wagon") {
return (
<Stack gap="md">
{!displayWagonPlan.length && !previewResult ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
</Paper>
) : null}
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
<Button
color="green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
>
Continue to containers
</Button>
)}
<Button variant="default" radius="md" onClick={() => void runPreview()}>
Refresh preview
</Button>
</Group>
) : null}
</Stack>
);
}
if (key === "container") {
return (
<Stack gap="md">
{!containerUnits.length ? (
<Paper p="md" radius="lg" 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}
/>
)}
{canEditBookings ? (
<Group>
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
<Button
variant="default"
radius="md"
onClick={() => setActiveStep(finalizeStep)}
>
Skip to finalize
</Button>
</Group>
) : null}
</Stack>
);
}
// finalize
return (
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: scheduleBrand.softSurface,
borderColor: scheduleBrand.mutedBorder,
}}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to depart</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan and moves the schedule to{" "}
<Text span fw={600} c="green.7">
SCHEDULED
</Text>
. Dispatch then begins rail movement and notifies the yard.
</Text>
</Stack>
</Group>
</Paper>
<Group>
{canFinalize ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
loading={finalize.isPending}
onClick={async () => {
try {
await finalize.mutateAsync(scheduleId);
toast({ title: "Schedule finalized" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize"),
variant: "destructive",
});
}
}}
>
Finalize schedule
</Button>
) : null}
{canDispatch ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
>
Dispatch train
</Button>
) : null}
{!canFinalize && !canDispatch ? (
<Text size="sm" c="dimmed">
No actions available for this schedule status.
</Text>
) : null}
</Group>
</Stack>
);
};
return (
<Stack gap="lg">
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
<Group gap="sm">
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
color="green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
>
Track train
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={
schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set"
}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
<StatTile
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
"en",
{ month: "short", day: "2-digit" },
)}
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
hour: "2-digit",
minute: "2-digit",
})}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
</SimpleGrid>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="light"
color={previewResult.valid ? "green" : "red"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Stack>
</Paper>
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Workflow header with ring progress */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Scheduling workflow
</Title>
<Text size="sm" c="dimmed">
{completedCount} of {stepsMeta.length} steps complete · expand any
step to edit
</Text>
</Stack>
</Group>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
{progressPct}%
</Text>
}
/>
</Group>
<WorkflowRail>
{stepsMeta.map((step, index) => (
<WorkflowStep
key={step.key}
index={index}
icon={step.icon}
title={step.title}
subtitle={step.subtitle}
state={
activeStep === index
? "active"
: step.complete
? "complete"
: "upcoming"
}
open={activeStep === index}
onToggle={() => toggleStep(index)}
rightSlot={renderStepRightSlot(step.key)}
>
{renderStepBody(step.key)}
</WorkflowStep>
))}
</WorkflowRail>
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{scheduleId ? (
<RescheduleTrainDialog
scheduleId={scheduleId}
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
opened={maintenanceOpen}
onClose={() => setMaintenanceOpen(false)}
onComplete={() => void detailQuery.refetch()}
/>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,709 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Button,
Card,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
import {
useAvailableLocomotives,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const splitDate = (value?: string | null) => {
if (!value) return { day: "—", time: "" };
const date = new Date(value);
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
return {
day: new Intl.DateTimeFormat("en", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(date),
time: new Intl.DateTimeFormat("en", {
hour: "2-digit",
minute: "2-digit",
}).format(date),
};
};
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;
}
return fallback;
};
export default function TrainScheduleV2ListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `Only locomotives currently at ${originLabel} are shown`;
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
const stats = useMemo(() => {
const base = {
total: allSchedules.length,
scheduled: 0,
dispatched: 0,
draft: 0,
weight: 0,
};
for (const s of allSchedules) {
if (s.status === "SCHEDULED") base.scheduled += 1;
if (s.status === "DISPATCHED") base.dispatched += 1;
if (s.status === "DRAFT") base.draft += 1;
base.weight += s.totalWeightTons ?? 0;
}
return base;
}, [allSchedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.freightType,
s.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [allSchedules, search, statusFilter, freightFilter]);
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<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "route",
header: "Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
),
},
{
id: "freight",
header: "Freight",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive?.code ? (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: "metrics",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<MetricChip value={row.original.wagonCount} label="wgn" />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={6} justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
}
>
Open
</Button>
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? (
<Button
variant="light"
color="teal"
size="compact-sm"
leftSection={<Navigation size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`,
)
}
>
Track
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="subtle"
color="red"
size="compact-sm"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({
id: row.original.id,
freightType: row.original.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel
</Button>
) : null}
</Group>
),
},
];
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
toast({
title: "Create failed",
description: parseError(err, "Could not create schedule"),
variant: "destructive",
});
}
};
const tableStatus = schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success";
return (
<Stack gap="lg">
<Group justify="flex-end">
<Button
size="md"
radius="lg"
color="green"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
icon={Train}
label="Total trains"
value={stats.total}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
graphPct={stats.total ? Math.round((stats.scheduled / stats.total) * 100) : 0}
/>
<StatTile
icon={Send}
label="Dispatched"
value={stats.dispatched}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={stats.total ? Math.round((stats.dispatched / stats.total) * 100) : 0}
/>
<StatTile
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
accent="#FB8C2E"
graph="area"
graphAccent="orange"
/>
</SimpleGrid>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
addLabel="Create schedule"
onAdd={() => setCreateOpen(true)}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<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: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => v && setFreightFilter(v)}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "schedules" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{!paged.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No train schedules found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => (
<ScheduleCard
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
}
/>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filtered.length}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
</Stack>
</Card>
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Text fw={600}>Create train schedule</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Schedules support both container and bulk bookings once assigned.
</Text>
<Select
label="Route"
placeholder="Select route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveYardHint}
</Text>
) : null}
<TextInput
label="Departure date"
type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
onChange={(e) => {
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
disabled={!routeId}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button color="green" loading={create.isPending} onClick={handleCreate}>
Create
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
function MetricChip({
value,
label,
subtle = false,
}: {
value: string | number;
label: string;
subtle?: boolean;
}) {
return (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Text size="sm" fw={700} c={subtle ? "gray.7" : "green.8"} lh={1.2}>
{value}
</Text>
{label ? (
<Text size="xs" c="dimmed" lh={1.2}>
{label}
</Text>
) : null}
</Group>
);
}
function ScheduleCard({
schedule,
onOpen,
onTrack,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
onTrack: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
return (
<Card
radius="lg"
padding={0}
withBorder
onClick={onOpen}
style={{
cursor: "pointer",
overflow: "hidden",
borderColor: "var(--mantine-color-gray-2)",
transition: "box-shadow 150ms ease, transform 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.transform = "";
}}
>
{/* accent strip */}
<Box style={{ height: 4, background: "linear-gradient(90deg, #FBD171, #F2A516)" }} />
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
<Text size="xs" c="dimmed">
{day} · {time}
</Text>
</Stack>
</Group>
<StatusPill status={schedule.status} />
</Group>
<Box
p="xs"
style={{
borderRadius: 10,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<RouteCorridor origin={schedule.origin} destination={schedule.destination} />
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant="light"
color="green"
size="sm"
radius="md"
style={{ flex: 1 }}
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
{canTrack ? (
<Button
variant="light"
color="teal"
size="sm"
radius="md"
leftSection={<Navigation size={15} />}
onClick={(e) => {
e.stopPropagation();
onTrack();
}}
>
Track
</Button>
) : null}
</Group>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
useEffect(() => {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
}, [toast]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
});
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
} finally {
setSaving(false);
}
};
return (
<Stack gap="lg" maw={720}>
<Stack gap={4}>
<Title order={3}>Train scheduling rules</Title>
<Text size="sm" c="dimmed">
Global limits applied when previewing and assigning bookings to trains.
</Text>
</Stack>
<Card radius="xl" padding="lg" withBorder>
<Stack gap="md">
<NumberInput
label="Max train length (m)"
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max train weight (T)"
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max 20ft container weight (T)"
description="Each individual 20ft container gross weight limit"
value={form.max20ftContainerWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: Number(value),
}))
}
min={0.001}
disabled={loading}
/>
<NumberInput
label="Max 20ft pair weight difference (T)"
description="When two 20ft containers share a wagon, |weight1 weight2| must not exceed this"
value={form.max20ftPairWeightDiffTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: Number(value),
}))
}
min={0}
disabled={loading}
/>
<Group justify="flex-end">
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,36 +1,110 @@
import { useParams } from 'react-router-dom';
import { useTrain } from '@/hooks/useTrains';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Skeleton } from '@/components/ui/skeleton';
// import { AssignWagonDialog } from '@/components/AssignWagonDialog';
// import { WagonsTable } from '@/components/WagonsTable';
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@edr/ui-common';
import { AssignWagonDialog } from '@/components/wagons/AssignWagonDialog';
import { WagonsTable } from '@/components/wagons/WagonsTable';
import { useParams, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core";
import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog";
import { WagonsTable } from "@/components/wagons/WagonsTable";
import { useTrain } from "@/hooks/useTrains";
export default function TrainDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: train, isLoading } = useTrain(id!);
if (isLoading) return <Skeleton className="h-96 w-full" />;
if (!train) return <div>Train not found</div>;
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!train) {
return (
<Text c="dimmed" py="xl">
Train not found
</Text>
);
}
const title = train.trainNumber || train.code;
const subtitle = train.trainName || "Unnamed train";
return (
<div className="space-y-6">
<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 Station:</span> {train.originStationId || '-'}</div>
<div><span className="font-medium">Destination:</span> {train.destinationStationId || '-'}</div>
</CardContent>
<Stack gap="md">
<Button
component={Link}
to="/dashboard/trains"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to trains
</Button>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Text fw={700} size="lg">
{title}
</Text>
<Text size="sm" c="dimmed">
{subtitle}
</Text>
</Stack>
<Badge variant="light" color="gray" size="lg">
{train.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<Stack gap={2}>
<Text size="xs" c="dimmed">
Code
</Text>
<Text size="sm" fw={500}>
{train.code}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Capacity
</Text>
<Text size="sm" fw={500}>
{train.capacityTons} tons
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Locomotive
</Text>
<Text size="sm" fw={500}>
{train.locomotiveNumber || "—"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Origin station
</Text>
<Text size="sm" fw={500}>
{train.originStationId || "—"}
</Text>
</Stack>
</SimpleGrid>
</Stack>
</Card>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Wagons</h2>
<AssignWagonDialog trainId={train.id} />
</div>
<WagonsTable trainId={train.id} />
</div>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Assigned wagons</Text>
<AssignWagonDialog trainId={train.id} />
</Group>
<WagonsTable trainId={train.id} />
</Stack>
</Card>
</Stack>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +0,0 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
const wagonSchema = z.object({
wagonNumber: z.string().min(1, 'Required'),
wagonTypeId: z.string().min(1, 'Required'),
maxPayloadWeight: z.coerce.number().min(0),
});
type WagonFormValues = z.infer<typeof wagonSchema>;
interface WagonFormProps {
initialValues?: Partial<WagonFormValues>;
onSubmit: (values: WagonFormValues) => void;
}
export function WagonForm({ initialValues, onSubmit }: WagonFormProps) {
const { data: wagonTypes, isLoading: loadingTypes } = useWagonTypes();
const form = useForm<WagonFormValues>({
resolver: zodResolver(wagonSchema),
defaultValues: {
wagonNumber: initialValues?.wagonNumber || '',
wagonTypeId: initialValues?.wagonTypeId || '',
maxPayloadWeight: initialValues?.maxPayloadWeight || 0,
},
});
const selectedTypeId = form.watch('wagonTypeId');
// Autofill maxPayloadWeight when type changes
useEffect(() => {
if (selectedTypeId && wagonTypes) {
const type = wagonTypes.find((t) => t.id === selectedTypeId);
if (type) {
// Only autofill if it's a new selection and field is at default or empty
const currentWeight = form.getValues('maxPayloadWeight');
if (!initialValues?.wagonTypeId || selectedTypeId !== initialValues.wagonTypeId) {
form.setValue('maxPayloadWeight', Number(type.capacityTons));
}
}
}
}, [selectedTypeId, wagonTypes, form, initialValues?.wagonTypeId]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="wagonNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Number</FormLabel>
<FormControl>
<Input placeholder="e.g. W12345" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="wagonTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingTypes}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{wagonTypes?.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxPayloadWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Max Payload Weight (Tons)</FormLabel>
<FormControl>
<Input type="number" step="0.001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -1,31 +0,0 @@
import { useWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function WagonsPage() {
const { data: wagons, isLoading } = useWagons();
if (isLoading) return <div>Loading wagons...</div>;
return (
<Card>
<CardHeader><CardTitle>All Wagons</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{wagons?.map((w:any) => (
<TableRow key={w.id}>
<TableCell>{w.wagonNumber}</TableCell>
<TableCell>{w.wagonTypeId}</TableCell>
<TableCell>{w.trainId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{w.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}