mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
automation of loading and unloading
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user