mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
mantine added to Train, wagon, containers and cargoes
This commit is contained in:
@@ -1,11 +1,155 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Container, Stack, Grid } from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import {
|
||||
detailStyles,
|
||||
type BookingDetailView,
|
||||
BookingDetailToolbar,
|
||||
BookingDetailHeader,
|
||||
BookingLifecycleStepper,
|
||||
BookingRouteCard,
|
||||
BookingContainersCard,
|
||||
BookingApprovalCard,
|
||||
BookingReviewNotesCard,
|
||||
BookingPaymentCard,
|
||||
BookingFactsCard,
|
||||
BookingDocumentsCard,
|
||||
} from "@/components/bookings/detail";
|
||||
|
||||
const BookingDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
const booking: BookingDetailView = {
|
||||
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
|
||||
reference: "BKG-2026-001456",
|
||||
status: "IN_TRANSIT",
|
||||
scheduledDate: "2026-06-15",
|
||||
totalAmount: 15750.5,
|
||||
paymentCurrency: "USD",
|
||||
paymentStatus: "PAID",
|
||||
tradeDirection: "IMPORT",
|
||||
freightType: "CONTAINER",
|
||||
priorityScore: 650,
|
||||
company: {
|
||||
id: "1",
|
||||
companyName: "Global Logistics Inc.",
|
||||
name: "Global Logistics Inc.",
|
||||
},
|
||||
originYard: { id: "1", label: "Port of Shanghai", code: "PVG" },
|
||||
destinationYard: { id: "2", label: "Port of Addis Ababa", code: "AAA" },
|
||||
serviceType: { id: "1", label: "Container Import Service", code: "CIS" },
|
||||
cargoType: { id: "1", label: "Electronics", code: "ELEC" },
|
||||
shippingLine: { id: "1", label: "Maersk Line", code: "MAE" },
|
||||
cargoTotalWeightVgm: 22.5,
|
||||
pnrCode: "PNR-2026-001456",
|
||||
createdAt: "2026-06-05T10:30:00Z",
|
||||
updatedAt: "2026-06-06T14:20:00Z",
|
||||
bookingContainers: [
|
||||
{
|
||||
id: "1",
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 11.25,
|
||||
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
{
|
||||
id: "1",
|
||||
stepOrder: 1,
|
||||
requiredRole: "LINE_STAFF",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T11:00:00Z",
|
||||
},
|
||||
// {
|
||||
// id: "2",
|
||||
// stepOrder: 2,
|
||||
// requiredRole: "DIRECTOR",
|
||||
// status: "APPROVED",
|
||||
// actionedAt: "2026-06-05T13:30:00Z",
|
||||
// },
|
||||
{
|
||||
id: "3",
|
||||
stepOrder: 3,
|
||||
requiredRole: "CEO",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T15:45:00Z",
|
||||
},
|
||||
],
|
||||
reviewNotes: [
|
||||
{
|
||||
id: "1",
|
||||
note: "Cargo declaration verified against shipping documents.",
|
||||
type: "VERIFICATION",
|
||||
createdAt: "2026-06-05T11:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
note: "VGM documentation received and processed.",
|
||||
type: "COMPLIANCE",
|
||||
createdAt: "2026-06-05T12:00:00Z",
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{ id: "1", name: "Bill_of_Lading.pdf", mimeType: "application/pdf" },
|
||||
{ id: "2", name: "VGM_Certificate.pdf", mimeType: "application/pdf" },
|
||||
{ id: "3", name: "Commercial_Invoice.pdf", mimeType: "application/pdf" },
|
||||
],
|
||||
};
|
||||
|
||||
const approvalSteps = booking.approvalSteps ?? [];
|
||||
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length;
|
||||
const totalSteps = approvalSteps.length;
|
||||
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Booking Detail"
|
||||
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams."
|
||||
/>
|
||||
<div style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<BookingDetailToolbar onBack={() => navigate(-1)} />
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: booking.reference },
|
||||
]}
|
||||
/>
|
||||
{/*
|
||||
<BookingDetailHeader
|
||||
booking={booking}
|
||||
approvedCount={approvedCount}
|
||||
totalSteps={totalSteps}
|
||||
/> */}
|
||||
|
||||
<BookingLifecycleStepper status={booking.status} />
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* 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} />
|
||||
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — summary sidebar */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingPaymentCard
|
||||
totalAmount={booking.totalAmount}
|
||||
currency={booking.paymentCurrency}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
/>
|
||||
<BookingFactsCard booking={booking} />
|
||||
<BookingDocumentsCard files={booking.files ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,111 +1,110 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FileSignature, Package } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Package,
|
||||
FileSignature,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
Container,
|
||||
Stack,
|
||||
Grid,
|
||||
Center,
|
||||
Loader,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingSurface,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
detailStyles,
|
||||
BookingRequestHero,
|
||||
BookingRouteServiceCard,
|
||||
BookingMileServicesCard,
|
||||
BookingCargoCard,
|
||||
BookingContractSummaryCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Separator,
|
||||
} from "@edr/ui-common";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } =
|
||||
useBookingDetail(id);
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
||||
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Loading booking…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Box style={detailStyles.page}>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="gray" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading booking…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !booking) {
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div
|
||||
className={cn(
|
||||
bookingSurface.sectionCard,
|
||||
"mx-auto max-w-md p-12 text-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Package className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
||||
<Box style={detailStyles.page}>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<Package size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Booking not found
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</p>
|
||||
</Text>
|
||||
<Button
|
||||
className="mt-6 gap-2"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to booking requests
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const amount = Number(booking.totalAmount);
|
||||
const showContractButton = [
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<Box style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
@@ -113,381 +112,66 @@ export default function BookingRequestDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.detailHero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
<Stack gap="lg" mt="sm">
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Package className="size-7" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Booking reference
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
{booking.reference}
|
||||
</h1>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</div>
|
||||
{booking.nextStep && (
|
||||
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||
<Building2 className="size-4 opacity-70" />
|
||||
{row.customerLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="size-4 opacity-70" />
|
||||
Scheduled {booking.scheduledDate}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="size-4 opacity-70" />
|
||||
Created{" "}
|
||||
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
||||
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Total value
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{booking.paymentStatus}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isFetching && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12 xl:gap-8">
|
||||
<div className="flex flex-col gap-6 xl:col-span-8">
|
||||
<RouteCard booking={booking} row={row} />
|
||||
<MileCard booking={booking} />
|
||||
<CargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<SectionShell
|
||||
icon={<Anchor className="size-4" />}
|
||||
title="Contract summary"
|
||||
subtitle="Generated terms"
|
||||
>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
|
||||
{booking.contractSummary}
|
||||
</pre>
|
||||
</SectionShell>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
bookingSurface.stickySidebar,
|
||||
"xl:col-span-4",
|
||||
)}
|
||||
>
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
|
||||
booking.status,
|
||||
) && (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className="px-5 py-4">
|
||||
<Button
|
||||
className="w-full gap-2 shadow-sm"
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
<FileSignature className="size-4" />
|
||||
View & sign contract
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionShell({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>{icon}</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={bookingSurface.sectionBody}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
booking,
|
||||
row,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
}) {
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Train className="size-4" />}
|
||||
title="Route & service"
|
||||
subtitle="Corridor and service level"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
|
||||
bookingGlass.activeTab,
|
||||
)}
|
||||
>
|
||||
<RouteEndpoint label="Origin" station={row.originLabel} />
|
||||
<div className="flex flex-col items-center gap-2 px-4">
|
||||
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
|
||||
<Train className="size-5 text-black" strokeWidth={1.75} />
|
||||
</div>
|
||||
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{booking.serviceType?.label ??
|
||||
booking.serviceType?.code ??
|
||||
"Rail service"}
|
||||
</Badge>
|
||||
</div>
|
||||
<RouteEndpoint label="Destination" station={row.destinationLabel} />
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<MetricTile label="Trade direction" value={booking.tradeDirection} />
|
||||
<MetricTile label="Freight type" value={booking.freightType} />
|
||||
<MetricTile
|
||||
label="Equipment return"
|
||||
value={booking.equipmentReturn ?? "—"}
|
||||
/>
|
||||
{booking.shippingLine && (
|
||||
<MetricTile
|
||||
label="Shipping line"
|
||||
value={
|
||||
booking.shippingLine.label ??
|
||||
booking.shippingLine.name ??
|
||||
booking.shippingLine.code ??
|
||||
"—"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MileCard({ booking }: { booking: BookingDetail }) {
|
||||
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Truck className="size-4" />}
|
||||
title="Mile services"
|
||||
subtitle="First and last mile"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{booking.firstMilePickupAddress && (
|
||||
<MetricTile
|
||||
label="First mile pickup"
|
||||
value={booking.firstMilePickupAddress}
|
||||
/>
|
||||
)}
|
||||
{booking.lastMileDeliveryAddress && (
|
||||
<MetricTile
|
||||
label="Last mile delivery"
|
||||
value={booking.lastMileDeliveryAddress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CargoCard({ booking }: { booking: BookingDetail }) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
return (
|
||||
<SectionShell
|
||||
icon={<Package className="size-4" />}
|
||||
title="Cargo specifications"
|
||||
subtitle="Freight and containers"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MetricTile
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Total VGM"
|
||||
value={`${booking.cargoTotalWeightVgm} tons`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
/>
|
||||
</div>
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-5" />
|
||||
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-3">Container type</th>
|
||||
<th className="px-4 py-3">Qty</th>
|
||||
<th className="px-4 py-3">VGM / unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
className="border-b border-border/60 last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{c.containerType?.label ??
|
||||
c.containerType?.code ??
|
||||
c.containerTypeId}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
||||
{c.quantity}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
||||
{c.vgmPerUnitTons} t
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteEndpoint({
|
||||
label,
|
||||
station,
|
||||
}: {
|
||||
label: string;
|
||||
station: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
||||
<div className={bookingSurface.sectionIconLg}>
|
||||
<MapPin className="size-5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({
|
||||
label,
|
||||
value,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
bookingSurface.metricTile,
|
||||
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Group,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Button as MantineButton,
|
||||
ThemeIcon,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
@@ -26,12 +40,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import {
|
||||
bookingGlass,
|
||||
bookingInput,
|
||||
bookingSurface,
|
||||
bookingTable,
|
||||
} from "@/components/bookings/booking-ui.styles";
|
||||
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";
|
||||
@@ -176,8 +185,18 @@ export default function BookingRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<BookingStatusBadge status={row.original.status} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
@@ -237,168 +256,189 @@ export default function BookingRequestsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.page}>
|
||||
<div className={bookingSurface.pageInner}>
|
||||
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||
<Container size="xxl" py="xl">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
||||
|
||||
<div className={bookingSurface.hero}>
|
||||
<div className={bookingSurface.heroGlow} />
|
||||
<div className={bookingSurface.heroSheen} />
|
||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
||||
bookingGlass.iconWellGreen,
|
||||
)}
|
||||
>
|
||||
<Inbox className="size-6" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Operations
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||
Booking requests
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
Track bookings from submission through payment and operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
disabled={isFetching}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isFetching && "animate-spin")}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
{/*
|
||||
<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)",
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<div className={bookingSurface.panel}>
|
||||
<div className={bookingSurface.panelToolbar}>
|
||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search reference or customer…"
|
||||
className={bookingInput.search}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
|
||||
)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="md" align="flex-start">
|
||||
<ThemeIcon
|
||||
size="lg"
|
||||
radius="lg"
|
||||
color="green"
|
||||
variant="light"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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> */}
|
||||
|
||||
{showEmpty ? (
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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 });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
) : (
|
||||
<div className={bookingSurface.tableWrap}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
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",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<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 reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
hasSearch={hasSearch}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={handleRowClick}
|
||||
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",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select as MantineSelect,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table as MantineTable,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -32,8 +51,15 @@ import {
|
||||
} from '@/hooks/useContainers';
|
||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||
import {
|
||||
useCreateLocomotive,
|
||||
useDecommissionLocomotive,
|
||||
useLocomotives,
|
||||
useUpdateLocomotive,
|
||||
} from '@/hooks/useLocomotives';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
import type { Wagon } from '@/services/wagon.service';
|
||||
import type { WagonType } from '@/services/wagon-types.service';
|
||||
@@ -63,6 +89,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
title: string;
|
||||
description: string;
|
||||
addLabel: string;
|
||||
entityLabel?: string;
|
||||
data?: T[];
|
||||
isLoading: boolean;
|
||||
columns: Column<T>[];
|
||||
@@ -72,6 +99,10 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
|
||||
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
|
||||
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
|
||||
removeActionLabel?: string;
|
||||
removeConfirmMessage?: string;
|
||||
removeSuccessMessage?: string;
|
||||
hideViewAction?: boolean;
|
||||
};
|
||||
|
||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||
@@ -139,6 +170,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
title,
|
||||
description,
|
||||
addLabel,
|
||||
entityLabel,
|
||||
data,
|
||||
isLoading,
|
||||
columns,
|
||||
@@ -148,6 +180,10 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeActionLabel = 'Delete',
|
||||
removeConfirmMessage,
|
||||
removeSuccessMessage,
|
||||
hideViewAction = false,
|
||||
}: FleetCrudPageProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -249,12 +285,13 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
};
|
||||
|
||||
const handleDelete = async (item: T) => {
|
||||
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
|
||||
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
|
||||
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
|
||||
try {
|
||||
await remove.mutateAsync(item.id);
|
||||
toast({ title: `${title.slice(0, -1)} deleted` });
|
||||
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
|
||||
} catch {
|
||||
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
|
||||
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,13 +352,15 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
{!hideViewAction ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title="Delete">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -502,64 +541,351 @@ export function TrainMasterDataPage() {
|
||||
|
||||
export function WagonTypesCrudPage() {
|
||||
const query = useWagonTypes();
|
||||
const create = useCreateWagonType();
|
||||
const update = useUpdateWagonType();
|
||||
const remove = useDeleteWagonType();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WagonType | null>(null);
|
||||
const [viewing, setViewing] = useState<WagonType | null>(null);
|
||||
const [form, setForm] = useState<Record<string, FormValue>>({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const pageSize = 10;
|
||||
const filtered = useMemo(() => {
|
||||
const queryText = search.trim().toLowerCase();
|
||||
const rows = query.data ?? [];
|
||||
if (!queryText) return rows;
|
||||
return rows.filter((type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(queryText),
|
||||
);
|
||||
}, [query.data, search]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((left, right) => {
|
||||
const result = String(left[sortKey] ?? '').localeCompare(String(right[sortKey] ?? ''), undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filtered, sortDirection, sortKey]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
const isSaving = create.isPending || update.isPending;
|
||||
|
||||
const toggleSort = (key: keyof WagonType) => {
|
||||
setPage(1);
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (type: WagonType) => {
|
||||
setEditing(type);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: type.code ?? '',
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const validateWagonType = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
|
||||
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
|
||||
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
|
||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const errors = validateWagonType();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
toast({ title: 'Save failed', description: Object.values(errors)[0], variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = normalizePayload(form);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await update.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: 'Wagon Type updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload);
|
||||
toast({ title: 'Wagon Type created' });
|
||||
}
|
||||
closeForm();
|
||||
} catch (error) {
|
||||
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
|
||||
setFieldErrors(backendFieldErrors);
|
||||
toast({ title: 'Save failed', description: message, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (type: WagonType) => {
|
||||
if (!window.confirm('Delete this wagon type?')) return;
|
||||
try {
|
||||
await remove.mutateAsync(type.id);
|
||||
toast({ title: 'Wagon Type deleted' });
|
||||
} catch {
|
||||
toast({ title: 'Delete failed', description: 'This wagon type may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const sortLabel = (key: keyof WagonType) => (sortKey === key ? (sortDirection === 'asc' ? ' ASC' : ' DESC') : '');
|
||||
|
||||
return (
|
||||
<FleetCrudPage<WagonType>
|
||||
title="Wagon Types"
|
||||
description="Manage wagon type capacities and load compatibility used by wagon master data."
|
||||
addLabel="Add Wagon Type"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateWagonType()}
|
||||
update={useUpdateWagonType()}
|
||||
remove={useDeleteWagonType()}
|
||||
searchText={(type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Load types',
|
||||
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
|
||||
},
|
||||
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
|
||||
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
|
||||
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Supported load types',
|
||||
placeholder: 'container, break-bulk',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
],
|
||||
onValueChange: (value) => ({ isActive: value === 'true' }),
|
||||
},
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
}}
|
||||
/>
|
||||
<Box p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Box>
|
||||
<Title order={2}>Wagon Types</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Manage wagon type capacities and load compatibility used by wagon master data.
|
||||
</Text>
|
||||
</Box>
|
||||
<MantineButton
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
closeForm();
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Wagon Type
|
||||
</MantineButton>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
maw={420}
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search wagon types"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<ScrollArea>
|
||||
<MantineTable striped highlightOnHover verticalSpacing="sm" miw={900}>
|
||||
<MantineTable.Thead>
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('code')}>
|
||||
Code{sortLabel('code')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('name')}>
|
||||
Name{sortLabel('name')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('capacityTons')}>
|
||||
Capacity (tons){sortLabel('capacityTons')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||
<MantineTable.Th>Load types</MantineTable.Th>
|
||||
<MantineTable.Th>Status</MantineTable.Th>
|
||||
<MantineTable.Th ta="right">Actions</MantineTable.Th>
|
||||
</MantineTable.Tr>
|
||||
</MantineTable.Thead>
|
||||
<MantineTable.Tbody>
|
||||
{paged.map((type) => (
|
||||
<MantineTable.Tr key={type.id}>
|
||||
<MantineTable.Td fw={600}>{type.code}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.name}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
|
||||
{type.isActive === false ? 'Inactive' : 'Active'}
|
||||
</MantineBadge>
|
||||
</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon variant="subtle" aria-label="View wagon type" onClick={() => setViewing(type)}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Edit wagon type" onClick={() => openEdit(type)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label="Delete wagon type"
|
||||
onClick={() => handleDelete(type)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
))}
|
||||
{!query.isLoading && filtered.length === 0 ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No wagon types found.
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
{query.isLoading ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
Loading...
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
</MantineTable.Tbody>
|
||||
</MantineTable>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
|
||||
{sorted.length}
|
||||
</Text>
|
||||
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Modal opened={formOpen} onClose={closeForm} title={editing ? 'Edit Wagon Type' : 'Add Wagon Type'} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label="Code"
|
||||
required
|
||||
value={String(form.code ?? '')}
|
||||
error={fieldErrors.code}
|
||||
onChange={(event) => setForm((current) => ({ ...current, code: event.currentTarget.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
value={String(form.name ?? '')}
|
||||
error={fieldErrors.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity (tons)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.capacityTons ?? 0)}
|
||||
error={fieldErrors.capacityTons}
|
||||
onChange={(value) => setForm((current) => ({ ...current, capacityTons: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Length (meters)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.lengthMeters ?? 0)}
|
||||
error={fieldErrors.lengthMeters}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
min={0}
|
||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
value={form.isActive ? 'true' : 'false'}
|
||||
data={[
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
]}
|
||||
onChange={(value) => setForm((current) => ({ ...current, isActive: value !== 'false' }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Supported load types"
|
||||
placeholder="container, break-bulk"
|
||||
value={Array.isArray(form.supportedLoadTypes) ? form.supportedLoadTypes.join(', ') : String(form.supportedLoadTypes ?? '')}
|
||||
onChange={(event) => setForm((current) => ({ ...current, supportedLoadTypes: event.currentTarget.value }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<MantineButton variant="default" type="button" onClick={closeForm}>
|
||||
Cancel
|
||||
</MantineButton>
|
||||
<MantineButton type="submit" loading={isSaving}>
|
||||
Save
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={Boolean(viewing)} onClose={() => setViewing(null)} title="Wagon Type details" centered>
|
||||
<Stack gap="xs">
|
||||
{viewing
|
||||
? Object.entries(viewing).map(([key, value]) => (
|
||||
<Group key={key} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{key}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="right">
|
||||
{Array.isArray(value) ? value.join(', ') : value == null ? '-' : String(value)}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
: null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -725,3 +1051,82 @@ export function CargoesCrudPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocomotivesCrudPage() {
|
||||
const query = useLocomotives();
|
||||
|
||||
return (
|
||||
<FleetCrudPage<Locomotive>
|
||||
title="Locomotives"
|
||||
entityLabel="Locomotive"
|
||||
description="Manage locomotive master data used by train scheduling and fleet operations."
|
||||
addLabel="Add Locomotive"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateLocomotive()}
|
||||
update={useUpdateLocomotive()}
|
||||
remove={useDecommissionLocomotive()}
|
||||
removeActionLabel="Decommission"
|
||||
removeConfirmMessage="Decommission this locomotive?"
|
||||
removeSuccessMessage="Locomotive decommissioned"
|
||||
searchText={(locomotive) =>
|
||||
[
|
||||
locomotive.code,
|
||||
locomotive.name,
|
||||
locomotive.locomotiveType,
|
||||
locomotive.status,
|
||||
].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
|
||||
{ key: 'locomotiveType', label: 'Type' },
|
||||
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{
|
||||
key: 'locomotiveType',
|
||||
label: 'Locomotive type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'DIESEL', label: 'Diesel' },
|
||||
{ value: 'ELECTRIC', label: 'Electric' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||
],
|
||||
},
|
||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
locomotiveType: 'DIESEL',
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
type RouteFormState = {
|
||||
name: string;
|
||||
milestones: string[];
|
||||
};
|
||||
|
||||
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
|
||||
|
||||
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
|
||||
|
||||
const routeStops = (route: RouteRecord) =>
|
||||
(route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
||||
|
||||
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 rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
|
||||
return Array.isArray(rawMessage)
|
||||
? rawMessage.join(', ')
|
||||
: rawMessage
|
||||
? String(rawMessage)
|
||||
: 'Save failed';
|
||||
};
|
||||
|
||||
export default function RoutesPage() {
|
||||
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 { toast } = useToast();
|
||||
|
||||
const routesQuery = useRoutes();
|
||||
const yardsQuery = useRouteYards();
|
||||
const createMutation = useCreateRoute();
|
||||
const updateMutation = useUpdateRoute();
|
||||
const deactivateMutation = useDeactivateRoute();
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return routesQuery.data ?? [];
|
||||
|
||||
return (routesQuery.data ?? []).filter((route) => {
|
||||
const searchable = [
|
||||
route.name,
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...routeStops(route),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchable.includes(query);
|
||||
});
|
||||
}, [routesQuery.data, search]);
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? []).map((yard) => ({
|
||||
value: yard.id,
|
||||
label: `${yard.label} (${yard.code})`,
|
||||
})),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (route: RouteRecord) => {
|
||||
setEditing(route);
|
||||
setForm({
|
||||
name: route.name,
|
||||
milestones: (route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yardId),
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const setMilestone = (index: number, yardId: string) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.map((value, currentIndex) =>
|
||||
currentIndex === index ? yardId : value,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
|
||||
};
|
||||
|
||||
const removeMilestone = (index: number) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
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',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
milestones: form.milestones.map((yardId) => ({ yardId })),
|
||||
isActive: editing?.isActive ?? true,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: 'Route updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
toast({ title: 'Route created' });
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
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' });
|
||||
} catch {
|
||||
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const availableOptionsForIndex = (index: number) => {
|
||||
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),
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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 }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,14 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
|
||||
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
import {
|
||||
ruleEngineSurface,
|
||||
ruleEngineTable,
|
||||
} from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -34,15 +32,8 @@ import {
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
getCoreRowModel,
|
||||
usePagination,
|
||||
useReactTable,
|
||||
@@ -177,15 +168,6 @@ const RuleEngineResourcePage = () => {
|
||||
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
||||
);
|
||||
|
||||
const cardTable = useReactTable({
|
||||
data: filteredRows,
|
||||
columns: [] as ColumnDef<RuleEngineRecord>[],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
|
||||
const handleApproveRate = useCallback(
|
||||
(record: RuleEngineRecord) => {
|
||||
@@ -209,14 +191,19 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
base.push({
|
||||
id: "actions",
|
||||
header: "Details",
|
||||
size: 120,
|
||||
meta: { headerClassName, cellClassName },
|
||||
header: "Actions",
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
meta: {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<RuleEngineRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
@@ -284,9 +271,9 @@ const RuleEngineResourcePage = () => {
|
||||
const itemLabel = config.label.toLowerCase();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className={ruleEngineSurface.pageCard}>
|
||||
<div className={ruleEngineSurface.pageCardToolbar}>
|
||||
<Stack gap="lg">
|
||||
<Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}>
|
||||
<Stack gap="md">
|
||||
<RuleEngineToolbar
|
||||
search={search}
|
||||
onSearchChange={(v) => {
|
||||
@@ -299,65 +286,64 @@ const RuleEngineResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredRows}
|
||||
status={tableStatus}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
pagination={paginationState}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
||||
footerClassName="border-t border-border bg-card"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{
|
||||
labels: {
|
||||
showing: "Showing",
|
||||
ofLabel: "of",
|
||||
items: itemLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RuleEngineCardGrid
|
||||
config={config}
|
||||
rows={filteredRows}
|
||||
status={tableStatus}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
itemLabel={itemLabel}
|
||||
table={cardTable}
|
||||
pagination={paginationState}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredRows}
|
||||
status={tableStatus}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
pagination={paginationState}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
||||
footerClassName="border-t border-border bg-card"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{
|
||||
labels: {
|
||||
showing: "Showing",
|
||||
ofLabel: "of",
|
||||
items: itemLabel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RuleEngineCardGrid
|
||||
config={config}
|
||||
rows={filteredRows}
|
||||
status={tableStatus}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
itemLabel={itemLabel}
|
||||
pagination={paginationState}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<RuleEngineFormDialog
|
||||
@@ -380,20 +366,23 @@ const RuleEngineResourcePage = () => {
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent className={ruleEngineSurface.dialogSm}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete record?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete record?"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
color="red"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -401,47 +390,51 @@ const RuleEngineResourcePage = () => {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
}}
|
||||
leftSection={remove.isPending && <Loader2 size={16} />}
|
||||
>
|
||||
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"}
|
||||
{remove.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Dialog open={chainOpen} onOpenChange={setChainOpen}>
|
||||
<DialogContent className={ruleEngineSurface.dialog}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approval chain</DialogTitle>
|
||||
<DialogDescription>Configured approval steps from the API.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Modal
|
||||
opened={chainOpen}
|
||||
onClose={() => setChainOpen(false)}
|
||||
title="Approval chain"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{chainLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<Group justify="center" p="xl">
|
||||
<Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} />
|
||||
</Group>
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
<>
|
||||
{(chainData ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No approval rules configured.</p>
|
||||
<Text size="sm" c="dimmed">No approval rules configured.</Text>
|
||||
) : (
|
||||
(chainData ?? []).map((step, index) => (
|
||||
<li
|
||||
key={String(step.id ?? index)}
|
||||
className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Role: {String(step.requiredRole ?? "—")}
|
||||
</p>
|
||||
</li>
|
||||
))
|
||||
<List spacing="md">
|
||||
{(chainData ?? []).map((step, index) => (
|
||||
<List.Item key={String(step.id ?? index)}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Role: {String(step.requiredRole ?? "—")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -180,6 +180,46 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "wagon-types",
|
||||
label: "Wagon Types",
|
||||
category: "configuration",
|
||||
subtitle: "Configure wagon classes used for capacity and train planning",
|
||||
searchPlaceholder: "Search wagon types by name or code...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
|
||||
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
|
||||
{
|
||||
id: "supportedLoadTypes",
|
||||
header: "Load types",
|
||||
accessorKey: "supportedLoadTypes",
|
||||
},
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
||||
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
|
||||
{
|
||||
name: "maxWagonsPerTrain",
|
||||
label: "Max wagons per train",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: "supportedLoadTypes",
|
||||
label: "Supported load types",
|
||||
type: "textarea",
|
||||
optional: true,
|
||||
placeholder: "CONTAINER, BULK",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "priority-rules",
|
||||
label: "Priority Rules",
|
||||
@@ -270,6 +310,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
category: "rules",
|
||||
subtitle: "VGM limits by container and trade direction",
|
||||
searchPlaceholder: "Search weight limit rules...",
|
||||
cardTitleKey: "containerType",
|
||||
cardSubtitleKey: "tradeDirection",
|
||||
columns: [
|
||||
{
|
||||
id: "containerType",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user