Warehouse Enhancemendt

This commit is contained in:
hagiye
2026-06-20 11:49:22 +03:00
1393 changed files with 334619 additions and 20282 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -226,7 +226,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: {

View File

@@ -1,72 +1,359 @@
import { useState } from "react";
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,
Card,
Container,
Divider,
Grid,
Group,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
Title,
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";
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?: Array<{ id: string; name: string; code: string }>;
service?: Array<{ id: string; name: string; code: string }>;
containers?: Array<{
size: string;
types: Array<{ id: string; name: string; code: string }>;
}>;
cargo_type?: Array<{ id: string; name: string; code: string }>;
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",
};
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 [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER");
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 [weight, setWeight] = useState<number>(100);
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
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: "NA",
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
scheduledDate: scheduledDate || new Date().toISOString(),
equipmentReturn,
tradeDirection: tradeDirection!,
paymentCurrency,
isHazardous,
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
serviceTypeId,
cargoTotalWeightVgm: weight,
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" && containerTypeId
? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }]
freightType === "CONTAINER"
? lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: l.vgmPerUnitTons,
}))
: undefined,
}),
onSuccess: async (booking) => {
@@ -79,36 +366,11 @@ export default function NewBookingPage() {
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
onError: () => toast.error("Failed to create booking"),
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
});
const yards = (refData?.yard ?? []).map((y) => ({
value: y.id,
label: y.name ?? y.code,
}));
const services = (refData?.service ?? []).map((s) => ({
value: s.id,
label: s.name ?? s.code,
}));
const containerTypes =
refData?.containers?.flatMap((g) =>
g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })),
) ?? [];
const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({
value: c.id,
label: c.name ?? c.code,
}));
const canSubmit =
originYardId &&
destinationYardId &&
serviceTypeId &&
scheduledDate &&
(!isGovernment || governmentInstitution.trim().length >= 2) &&
(freightType === "BULK" ? cargoTypeId : containerTypeId);
return (
<Container size="md" py="xl">
<Container size="xl" py="lg">
<Breadcrumbs
items={[
{ label: "Operations" },
@@ -116,122 +378,471 @@ export default function NewBookingPage() {
{ label: "Create" },
]}
/>
<Title order={2} mt="lg" mb="md">
Create booking (staff)
</Title>
<Card withBorder padding="lg" radius="lg">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to 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
/>
) : null}
<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>
<Select
label="Freight type"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightType}
onChange={(v) => setFreightType((v as "CONTAINER" | "BULK") ?? "CONTAINER")}
/>
<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"
/>
)}
<Group grow>
<Select
label="Origin yard"
data={yards}
value={originYardId}
onChange={setOriginYardId}
searchable
disabled={isLoading}
/>
<Select
label="Destination yard"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
searchable
disabled={isLoading}
/>
</Group>
<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>
<Select
label="Service type"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<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>
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
<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>
<NumberInput
label="Total weight (tons)"
value={weight}
onChange={(v) => setWeight(Number(v) || 0)}
min={0}
/>
{/* 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>
);
})}
{freightType === "CONTAINER" ? (
<Select
label="Container type"
data={containerTypes}
value={containerTypeId}
onChange={setContainerTypeId}
searchable
/>
) : (
<Select
label="Cargo type"
data={cargoTypes}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
/>
)}
<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>
)}
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate("/dashboard/booking-requests")}
>
Cancel
</Button>
<Button
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
</Group>
<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>
{isGovernment ? (
<Text size="xs" c="dimmed">
Government bookings skip the commercial 3-hour hold and appear in the
priority lane on the Operations tab.
</Text>
) : null}
</Stack>
</Card>
{/* RIGHT — sticky summary */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 16 }}>
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group gap="sm" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<Weight size={17} />
</ThemeIcon>
<Text fw={700}>Summary</Text>
</Group>
<Group gap="xs" mb="md">
<Badge variant="light" color={freightType === "BULK" ? "orange" : "teal"} radius="sm">
{freightType === "BULK" ? "Bulk" : "Container"}
</Badge>
<Badge variant="light" color="blue" radius="sm">
{tradeDirection}
</Badge>
{isGovernment ? (
<Badge variant="light" color="grape" radius="sm">
Government
</Badge>
) : null}
{isHazardous ? (
<Badge variant="light" color="red" radius="sm" leftSection={<Flame size={10} />}>
Hazardous
</Badge>
) : null}
</Group>
<Stack gap={8}>
{freightType === "CONTAINER" ? (
<>
<SummaryRow label="Container lines" value={String(lines.length)} />
<SummaryRow label="Total containers" value={String(totalContainers)} />
</>
) : null}
<SummaryRow label="Total VGM weight" value={fmtTons(cargoTotalWeightVgm)} strong />
<Divider my={4} />
<SummaryRow
label="Route"
value={
originYardId && destinationYardId
? `${yards.find((y) => y.value === originYardId)?.label ?? "—"}${
yards.find((y) => y.value === destinationYardId)?.label ?? "—"
}`
: "Not set"
}
/>
<SummaryRow
label="Departure"
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"}
/>
</Stack>
{sameYard ? (
<Group gap={6} mt="md" c="red.7">
<AlertTriangle size={14} />
<Text size="xs">Origin and destination must differ.</Text>
</Group>
) : null}
<Stack gap="sm" mt="lg">
<Button
size="md"
fullWidth
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
leftSection={<Ship size={18} />}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
Cancel
</Button>
</Stack>
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
<Text size="xs" c="dimmed">
{isGovernment
? "Government bookings skip the commercial 3-hour hold and enter the priority lane."
: "Overweight container lines are allowed here and flagged later at scheduling."}
</Text>
</Group>
</Paper>
</Box>
</Grid.Col>
</Grid>
</Container>
);
}
function SummaryRow({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" fw={strong ? 700 : 500} ta="right" style={{ minWidth: 0 }} truncate>
{value}
</Text>
</Group>
);
}

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ import {
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
@@ -104,6 +105,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
removeConfirmMessage?: string;
removeSuccessMessage?: string;
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
rowActions?: (item: T) => React.ReactNode;
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -185,6 +188,7 @@ function FleetCrudPage<T extends { id: string }>({
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
rowActions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -353,6 +357,7 @@ function FleetCrudPage<T extends { id: string }>({
))}
<TableCell>
<div className="flex justify-end gap-1">
{rowActions?.(item)}
{!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
@@ -1065,7 +1070,18 @@ export function CargoesCrudPage() {
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
{
key: 'receiverName',
label: 'Proof of delivery',
render: (cargo) =>
cargo.status === 'DELIVERED' && cargo.receiverName
? `${cargo.receiverName}${cargo.deliveredAt ? ` · ${new Date(cargo.deliveredAt).toLocaleDateString()}` : ''}`
: '—',
},
]}
rowActions={(cargo) =>
cargo.status === 'LOADED' ? <DeliverCargoDialog cargoId={cargo.id} /> : null
}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },

View File

@@ -1,7 +1,8 @@
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 { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
import { Badge } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
@@ -16,7 +17,9 @@ 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,
@@ -38,12 +41,30 @@ const FleetResourcePage = () => {
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 { data: allRows = [], isLoading, isError, error } = useFleetList(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();
@@ -51,17 +72,24 @@ const FleetResourcePage = () => {
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) return [];
if (!hasStatusColumn || usesServerListFilters) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
@@ -71,7 +99,7 @@ const FleetResourcePage = () => {
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn]);
}, [allRows, hasStatusColumn, usesServerListFilters]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
@@ -91,14 +119,41 @@ const FleetResourcePage = () => {
(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]);
}, [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);
@@ -109,6 +164,7 @@ const FleetResourcePage = () => {
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -121,10 +177,16 @@ const FleetResourcePage = () => {
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
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>;
@@ -138,7 +200,7 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config]);
}, [allRows, search, statusFilter, config, usesServerListFilters]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -154,6 +216,8 @@ const FleetResourcePage = () => {
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
size: col.size || 150,
minSize: col.size ? Math.max(col.size - 20, 80) : 80,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
@@ -184,7 +248,7 @@ const FleetResourcePage = () => {
});
return base;
}, [config]);
}, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -230,10 +294,23 @@ const FleetResourcePage = () => {
const itemLabel = config.label.toLowerCase();
return (
<Stack gap="md">
<Stack gap="lg" style={{ maxWidth: "100%" }}>
<Box>
<Stack gap="xs">
<div>
<Text size="lg" fw={700} c="dark">
{config.label}
</Text>
<Text size="sm" c="dimmed">
{config.subtitle}
</Text>
</div>
</Stack>
</Box>
<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%">
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<FleetToolbar
search={search}
onSearchChange={setSearch}
@@ -247,56 +324,96 @@ const FleetResourcePage = () => {
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
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)" } }}
/>
listFilterSelects ? (
<Group gap="xs" wrap="wrap">
{listFilterSelects.map((filter) => (
<Group key={filter.key} gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...filter.data].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={filter.value === option.value ? "filled" : "outline"}
color="green"
onClick={() => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: option.value,
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
>
{option.label}
</Button>
))}
</Group>
</Group>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Group gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">Status:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
color="green"
onClick={() => setStatusFilter(option.value)}
>
{option.label}
</Button>
))}
</Group>
</Group>
) : 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 } }}
/>
)}
/>
<Box style={{ overflowX: "auto", width: "100%", minWidth: 0 }}>
<div style={{ minWidth: "max-content" }}>
<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 } }}
/>
)}
/>
</div>
</Box>
) : (
<FleetCardGrid
config={config}

View File

@@ -1,5 +1,4 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
@@ -7,22 +6,30 @@ export type FleetResourceSlug =
| "trains"
| "wagons"
| "containers"
| "cargoes";
| "cargoes"
| "vehicles"
| "drivers";
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";
| "containers"
| "yards";
export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
size?: number;
}
export interface FleetFormFieldDef extends FormFieldDef {
@@ -30,6 +37,14 @@ export interface FleetFormFieldDef extends FormFieldDef {
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;
@@ -39,6 +54,8 @@ export interface FleetResourceConfig {
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>;
@@ -59,6 +76,8 @@ export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
wagons: "/dashboard/wagons",
containers: "/dashboard/containers",
cargoes: "/dashboard/cargoes",
vehicles: "/dashboard/vehicles",
drivers: "/dashboard/drivers",
};
const LOCOMOTIVE_TYPE_OPTIONS = [
@@ -80,11 +99,38 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const WAGON_READINESS_OPTIONS = [
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
{ label: "Car", value: "CAR" },
{ label: "Bus", value: "BUS" },
{ label: "Trailer", value: "TRAILER" },
{ label: "Tanker", value: "TANKER" },
{ label: "Flatbed", value: "FLATBED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
{ label: "Hybrid", value: "HYBRID" },
];
const VEHICLE_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Retired", value: "RETIRED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const DRIVER_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Suspended", value: "SUSPENDED" },
{ label: "On leave", value: "ON_LEAVE" },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
@@ -101,12 +147,27 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
cardSubtitleKey: "locomotiveType",
searchKeys: ["code", "name", "locomotiveType", "status"],
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" },
@@ -116,6 +177,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ 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" },
@@ -127,6 +189,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
@@ -187,14 +250,28 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
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: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
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: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
@@ -202,7 +279,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ 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: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ 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" },
],
@@ -211,7 +288,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",
},
@@ -301,6 +378,122 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
status: "PENDING",
},
},
{
slug: "vehicles",
label: "Vehicles",
subtitle: "Manage vehicle master data for fleet operations",
basePath: "/dashboard/vehicles",
addLabel: "Add Vehicle",
entityLabel: "Vehicle",
searchPlaceholder: "Search vehicles…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "plateNumber",
cardCodeKey: "plateNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: VEHICLE_STATUS_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", size: 140 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 140 },
{ id: "model", header: "Model", accessorKey: "model", size: 120 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 100 },
{ id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 },
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", size: 110 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
],
emptyValues: {
plateNumber: "",
vehicleType: "TRUCK",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
status: "ACTIVE",
description: "",
},
},
{
slug: "drivers",
label: "Drivers",
subtitle: "Manage driver records and licenses",
basePath: "/dashboard/drivers",
addLabel: "Add Driver",
entityLabel: "Driver",
searchPlaceholder: "Search drivers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "firstName",
cardCodeKey: "licenseNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: DRIVER_STATUS_OPTIONS,
},
],
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
{ id: "firstName", header: "First Name", accessorKey: "firstName", size: 120 },
{ id: "lastName", header: "Last Name", accessorKey: "lastName", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", size: 120 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true },
{ name: "lastName", label: "Last Name", type: "text", required: true },
{ name: "email", label: "Email", type: "email", required: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
{ name: "address", label: "Address", type: "textarea" },
{ name: "emergencyContact", label: "Emergency Contact", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
licenseNumber: "",
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dateOfBirth: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],
address: "",
emergencyContact: "",
notes: "",
},
},
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>

View File

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

View File

@@ -296,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);
@@ -307,7 +315,7 @@ const RuleEngineResourcePage = () => {
},
);
} else {
create.mutate(values, {
create.mutate(payload, {
onSuccess: () => {
setFormOpen(false);
setEditing(null);

View File

@@ -14,7 +14,7 @@ export type ColumnFormat =
| "entityLabel"
| "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea";
export interface ResourceColumn {
id: string;
@@ -34,6 +34,10 @@ export interface FormFieldDef {
optional?: boolean;
options?: { label: string; value: string }[];
placeholder?: string;
description?: string;
disabled?: boolean;
/** Hide this field when another field currently equals one of these values. */
hideWhen?: { field: string; equals: string[] };
}
export interface RuleEngineOrderConfig {
@@ -111,8 +115,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 => ({
@@ -229,29 +238,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" },
],
},
@@ -430,7 +451,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 },
@@ -504,7 +524,7 @@ export const getCategorySidebarChildren = (
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-rules";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-configs";
/** @deprecated Use DEFAULT_CONFIGURATION_SLUG */
export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
Container as ContainerIcon,
Eye,
LayoutGrid,
Navigation,
Package,
Route as RouteIcon,
Send,
@@ -51,6 +52,7 @@ import {
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";
@@ -100,9 +102,11 @@ export default function TrainScheduleV2DetailPage() {
? {
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],
[schedule, scheduleId],
);
const eligibleFreightType =
@@ -618,7 +622,7 @@ export default function TrainScheduleV2DetailPage() {
}}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
@@ -710,52 +714,30 @@ export default function TrainScheduleV2DetailPage() {
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<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="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<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} c="white" fw={700}>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge
variant="white"
c="green.8"
radius="sm"
style={{ fontWeight: 600 }}
>
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
onDark
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
@@ -771,42 +753,67 @@ export default function TrainScheduleV2DetailPage() {
</Group>
</Stack>
</Group>
{schedule.status !== "DISPATCHED" ? (
<Button
variant="white"
c="green.8"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
<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
onDark
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
onDark
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
<StatTile
onDark
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
onDark
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
@@ -817,6 +824,9 @@ export default function TrainScheduleV2DetailPage() {
hour: "2-digit",
minute: "2-digit",
})}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
</SimpleGrid>
@@ -824,8 +834,8 @@ export default function TrainScheduleV2DetailPage() {
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
variant="light"
color={previewResult.valid ? "green" : "red"}
leftSection={
<Box
w={8}
@@ -907,6 +917,8 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{scheduleId ? (
<RescheduleTrainDialog
scheduleId={scheduleId}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
@@ -8,16 +8,14 @@ import {
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
@@ -81,7 +79,7 @@ export default function TrainScheduleV2ListPage() {
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
@@ -89,6 +87,21 @@ export default function TrainScheduleV2ListPage() {
[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(() => {
@@ -253,6 +266,21 @@ export default function TrainScheduleV2ListPage() {
>
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"
@@ -313,94 +341,54 @@ export default function TrainScheduleV2ListPage() {
return (
<Stack gap="lg">
{/* Hero banner */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
{/* decorative glow */}
<Box
style={{
position: "absolute",
top: -90,
right: -60,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.12)",
filter: "blur(8px)",
pointerEvents: "none",
}}
/>
<Box
style={{
position: "absolute",
bottom: -120,
right: 120,
width: 220,
height: 220,
borderRadius: "50%",
background: "rgba(255,255,255,0.06)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={4}>
<Title order={2} c="white" fw={700}>
Train Schedules
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
Plan departures, allocate bookings, and dispatch trains across
every corridor.
</Text>
</Stack>
</Group>
<Button
size="md"
radius="lg"
variant="white"
c="green.8"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<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 onDark icon={Train} label="Total trains" value={stats.total} />
<StatTile
onDark
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
/>
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile
onDark
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
/>
</SimpleGrid>
</Stack>
</Paper>
<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}>
@@ -493,6 +481,11 @@ export default function TrainScheduleV2ListPage() {
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
}
/>
))}
</SimpleGrid>
@@ -528,6 +521,11 @@ export default function TrainScheduleV2ListPage() {
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveYardHint}
</Text>
) : null}
<TextInput
label="Departure date"
type="datetime-local"
@@ -539,7 +537,7 @@ export default function TrainScheduleV2ListPage() {
/>
<Select
label="Locomotive"
placeholder="Select locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
@@ -547,6 +545,10 @@ export default function TrainScheduleV2ListPage() {
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)}>
@@ -601,11 +603,14 @@ function MetricChip({
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"
@@ -628,11 +633,11 @@ function ScheduleCard({
}}
>
{/* accent strip */}
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} />
<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="green">
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
@@ -667,20 +672,37 @@ function ScheduleCard({
</Group>
</Group>
<Button
variant="light"
color="green"
size="sm"
radius="md"
fullWidth
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
<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>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Stack,
Table,
Text,
} from '@mantine/core';
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
InspectionReportModal,
VisualEmptyState,
WarehouseHero,
formatDate,
} from '@/components/warehouses';
import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import type { ArrivalQueueItem } from '@/types/warehouse';
function inspectionBadge(status: string | null) {
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
const color = status === 'PASSED' ? 'green' : status === 'FAILED' ? 'red' : 'orange';
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
}
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
export default function ArrivalQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading } = useArrivalQueue();
const autoUnload = useAutoUnloadArrived();
const unloadOne = useUnloadBooking();
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
const items = data ?? [];
const handleAutoUnload = async () => {
try {
const res = await autoUnload.mutateAsync();
const r = res.data;
toast({
title: 'Auto-unload complete',
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
});
} catch {
toast({ variant: 'destructive', title: 'Auto-unload failed' });
}
};
const handleUnloadOne = async (item: ArrivalQueueItem) => {
try {
await unloadOne.mutateAsync({ bookingId: item.bookingId });
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
} catch {
toast({ variant: 'destructive', title: 'Unload failed' });
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Arrival / Unloading Queue"
subtitle="Arrived bookings ready to unload, store and inspect."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{items.length} arrived booking(s)</Text>
<Button
color="orange"
leftSection={<PackageOpen size={16} />}
loading={autoUnload.isPending}
onClick={handleAutoUnload}
>
Auto Unload Arrived Bookings
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : items.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived bookings"
description="Bookings in transit that arrive appear here for unloading and inspection."
/>
) : (
<Table.ScrollContainer minWidth={1100}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Cargo / Container</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.bookingId}>
<Table.Td>
<Text fw={600} size="sm">{item.bookingReference}</Text>
</Table.Td>
<Table.Td>{item.customer ?? '—'}</Table.Td>
<Table.Td>{item.container ?? item.cargo ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivalDate)}</Text>
</Table.Td>
<Table.Td>{item.facility ?? '—'}</Table.Td>
<Table.Td>{item.warehouse ?? '—'}</Table.Td>
<Table.Td>{item.yard ?? '—'}</Table.Td>
<Table.Td>{item.zone ?? '—'}</Table.Td>
<Table.Td>
{item.unloaded ? (
<Badge variant="light" color="green" size="sm">
{item.currentStatus ?? 'RECEIVED'}
</Badge>
) : (
<Badge variant="light" color="orange" size="sm">
Not unloaded
</Badge>
)}
</Table.Td>
<Table.Td>{inspectionBadge(item.inspectionStatus)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{!item.unloaded && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Truck size={14} />}
loading={unloadOne.isPending}
onClick={() => handleUnloadOne(item)}
>
Unload
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<ClipboardList size={14} />}
onClick={() => setInspectInventoryId(item.inventoryId)}
>
Inspect
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/warehouse-inventory')}
>
Inventory
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<InspectionReportModal
opened={Boolean(inspectInventoryId)}
onClose={() => setInspectInventoryId(null)}
inventoryId={inspectInventoryId}
/>
</Container>
);
}

View File

@@ -0,0 +1,38 @@
import { Card, Container, Stack } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
/** Items that are LOADED and awaiting dispatch (train departure). */
export default function DispatchQueuePage() {
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
const items = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="route"
title="Dispatch Queue"
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
/>
<Card withBorder radius="md" padding="lg">
{!isLoading && items.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={items} isLoading={isLoading} />
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -3,7 +3,7 @@ import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, Te
import { Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
import {
useInventoryInquiry,
useWarehouseYards,
@@ -61,21 +61,21 @@ export default function InventoryInquiryPage() {
label="Booking number"
placeholder="e.g. BKG-00123"
value={draft.bookingNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, bookingNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Container number"
placeholder="e.g. MSKU1234567"
value={draft.containerNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, containerNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Goods name"
placeholder="e.g. Coffee"
value={draft.goodsName ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, goodsName: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
w={180}
/>
<Select
@@ -139,6 +139,12 @@ export default function InventoryInquiryPage() {
<Center py="xl">
<Loader />
</Center>
) : results.length === 0 ? (
<VisualEmptyState
variant="container"
title="No items found"
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
/>
) : (
<WarehouseInquiryTable results={results} />
)}

View File

@@ -0,0 +1,86 @@
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useWarehouseLoadings();
const loadings = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="wagon"
title="Loaded Inventory"
subtitle="Items loaded onto wagons, with their loading records."
/>
<Card withBorder radius="md" padding="lg">
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : loadings.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<Table.ScrollContainer minWidth={760}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Loaded Weight (kg)</Table.Th>
<Table.Th>Loaded At</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadings.map((l) => (
<Table.Tr key={l.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
</Text>
</Group>
</Table.Td>
<Table.Td>
{l.inventory?.warehouse
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
: '—'}
</Table.Td>
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
size="sm"
>
{l.inventory?.status ?? 'LOADED'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core';
import { CreditCard, Eye, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
InventoryWorkbench,
VisualEmptyState,
WarehouseHero,
formatNumber,
} from '@/components/warehouses';
import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
/**
* Loading Queue — manage inventory through the loading workflow.
* Tabs:
* - Ready to Load: READY_FOR_LOADING + booking PAID (can Mark as Loaded)
* - Pending Payment: READY_FOR_LOADING + booking not PAID (no Load action)
* - Loaded Inventory: LOADED (can Dispatch)
* - Dispatch Queue: LOADED (can Dispatch)
*/
export default function LoadingQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const autoLoad = useAutoLoadReady();
const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({
status: 'READY_FOR_LOADING',
});
const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' });
const handleAutoLoad = async () => {
try {
const res = await autoLoad.mutateAsync();
const r = res.data;
toast({
title: 'Auto-load complete',
description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`,
});
} catch {
toast({ variant: 'destructive', title: 'Auto-load failed' });
}
};
const readyItems = readyData ?? [];
const loadedItems = loadedData ?? [];
const paidItems = useMemo(() => readyItems.filter(isPaid), [readyItems]);
const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="wagon"
secondaryVariant="cargo"
title="Loading Queue"
subtitle="Manage bookings and inventory through the loading workflow."
/>
<Group justify="flex-end">
<Button
color="green"
leftSection={<Truck size={16} />}
loading={autoLoad.isPending}
onClick={handleAutoLoad}
>
Auto Load Ready Items
</Button>
</Group>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab
value="ready"
leftSection={
<Badge size="xs" color="green">
{paidItems.length}
</Badge>
}
>
Ready to Load
</Tabs.Tab>
<Tabs.Tab
value="pending"
leftSection={
<Badge size="xs" color="orange">
{unpaidItems.length}
</Badge>
}
>
Pending Payment
</Tabs.Tab>
<Tabs.Tab
value="loaded"
leftSection={
<Badge size="xs" color="teal">
{loadedItems.length}
</Badge>
}
>
Loaded Inventory
</Tabs.Tab>
<Tabs.Tab
value="dispatch"
leftSection={
<Badge size="xs" color="blue">
{loadedItems.length}
</Badge>
}
>
Dispatch Queue
</Tabs.Tab>
</Tabs.List>
{/* Ready to Load — PAID bookings, can be marked Loaded */}
<Tabs.Panel value="ready" pt="md">
{!readyLoading && paidItems.length === 0 ? (
<VisualEmptyState
variant="wagon"
title="Nothing ready to load"
description="Paid bookings marked Ready For Loading appear here, ready to load onto a wagon."
/>
) : (
<InventoryWorkbench items={paidItems} isLoading={readyLoading} />
)}
</Tabs.Panel>
{/* Pending Payment — unpaid bookings, read-only (no Load action) */}
<Tabs.Panel value="pending" pt="md">
{!readyLoading && unpaidItems.length === 0 ? (
<VisualEmptyState
variant="cargo"
title="No unpaid bookings"
description="Ready-for-loading items whose booking is not yet PAID appear here."
/>
) : (
<PendingPaymentTable items={unpaidItems} onNavigate={navigate} />
)}
</Tabs.Panel>
{/* Loaded Inventory — LOADED items, can Dispatch */}
<Tabs.Panel value="loaded" pt="md">
{!loadedLoading && loadedItems.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Items loaded onto a wagon appear here, ready to dispatch."
/>
) : (
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)}
</Tabs.Panel>
{/* Dispatch Queue — LOADED items awaiting departure */}
<Tabs.Panel value="dispatch" pt="md">
{!loadedLoading && loadedItems.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)}
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
);
}
interface PendingPaymentTableProps {
items: WarehouseInventoryItem[];
onNavigate: (path: string) => void;
}
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
return (
<Table.ScrollContainer minWidth={820}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Weight (kg)</Table.Th>
<Table.Th>Payment</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fw={600} size="sm">
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
</Text>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<Badge color="orange" variant="light" size="sm">
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,229 +1,122 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Card,
Container,
Group,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
Container as ContainerIcon,
Layers,
Package,
ClipboardList,
ShieldCheck,
PackageCheck,
PackagePlus,
PackageSearch,
CircleCheck,
Send,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
useWarehouseDashboardSummary,
useWarehouseFacilities,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter } from '@/types/warehouse';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
/** Brand palette: alternating orange + light green. */
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
interface Metric {
key: keyof WarehouseDashboard;
label: string;
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<InventoryFilter>({});
const facilitiesQuery = useWarehouseFacilities();
const warehousesQuery = useWarehouses(filter.facilityId ? { stationId: filter.facilityId } : undefined);
const summaryQuery = useWarehouseDashboardSummary(filter);
const counts = summaryQuery.data ?? {
totalWarehouses: 0,
totalInventory: 0,
receivedToday: 0,
stored: 0,
reserved: 0,
readyForLoading: 0,
loaded: 0,
dispatched: 0,
};
const facilityOptions = useMemo(
() =>
(facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
})),
[facilitiesQuery.data],
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehousesQuery.data],
);
const loading = summaryQuery.isLoading;
const hasError = summaryQuery.isError;
const cards: Array<{
label: string;
value: number;
icon: typeof WarehouseIcon;
color: string;
href: string;
disabled?: boolean;
}> = [
{
label: 'Total warehouses',
value: counts.totalWarehouses,
icon: WarehouseIcon,
color: 'indigo',
href: '/dashboard/warehouses/list',
},
{
label: 'Total inventory',
value: counts.totalInventory,
icon: Package,
color: 'gray',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Received today',
value: counts.receivedToday,
icon: PackagePlus,
color: 'orange',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Stored',
value: counts.stored,
icon: Layers,
color: 'blue',
href: '/dashboard/warehouse-inventory?status=STORED',
},
{
label: 'Reserved',
value: counts.reserved,
icon: ClipboardCheck,
color: 'grape',
href: '/dashboard/warehouse-inventory?status=RESERVED',
},
{
label: 'Ready for loading',
value: counts.readyForLoading,
icon: ContainerIcon,
color: 'cyan',
href: '/dashboard/warehouse-inventory?status=READY_FOR_LOADING',
},
{
label: 'Loaded',
value: counts.loaded,
icon: Truck,
color: 'teal',
href: '/dashboard/warehouse-inventory?status=LOADED',
},
{
label: 'Dispatched',
value: counts.dispatched,
icon: Send,
color: 'green',
href: '/dashboard/warehouse-inventory?status=DISPATCHED',
},
];
const { data, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<Stack gap="xl" mt="sm">
<Card withBorder radius="md" padding="xl" bg="gray.0">
<Group justify="space-between" align="center" gap="xl">
<div>
<Title order={1}>Warehouse Dashboard</Title>
<Text c="dimmed" size="lg" mt={6}>
Live overview of warehouse capacity and inventory lifecycle.
</Text>
</div>
<Group gap="xs" visibleFrom="sm">
<WarehouseIcon size={58} color="var(--mantine-color-green-6)" />
<Truck size={58} color="var(--mantine-color-orange-6)" />
</Group>
</Group>
</Card>
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="warehouse"
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
{hasError && (
<Alert color="yellow" variant="light" title="Some dashboard metrics could not be loaded">
Available cards still show data from the APIs that responded.
</Alert>
)}
<Card withBorder radius="md" padding="lg">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All facilities"
clearable
searchable
data={facilityOptions}
value={filter.facilityId ?? null}
onChange={(value) =>
setFilter((current) => ({ ...current, facilityId: value ?? undefined, warehouseId: undefined }))
}
w={260}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) => setFilter((current) => ({ ...current, warehouseId: value ?? undefined }))}
w={260}
/>
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{cards.map((card) => {
const Icon = card.icon;
return (
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={card.label}
withBorder
radius="md"
padding="xl"
component="button"
type="button"
disabled={card.disabled}
onClick={() => navigate(card.href)}
key={metric.key}
radius="lg"
padding="lg"
onClick={() => navigate(metric.to)}
style={{
cursor: card.disabled ? 'not-allowed' : 'pointer',
opacity: card.disabled ? 0.65 : 1,
textAlign: 'left',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: 'pointer',
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
border: `1px solid ${metric.theme.border}`,
transition: 'box-shadow 150ms ease, transform 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '';
e.currentTarget.style.transform = '';
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={10}>
<Text tt="uppercase" fw={700} c="dimmed" size="sm">
{card.label}
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
{loading ? (
<Skeleton height={38} width={76} radius="sm" />
) : (
<Text fw={800} size="36px" lh={1}>
{card.value.toLocaleString()}
</Text>
)}
</Stack>
<ThemeIcon color={card.color} variant="light" size={52} radius="md">
<Icon size={28} />
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="filled"
size={46}
radius="md"
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
</Container>
);

View File

@@ -19,26 +19,22 @@ import {
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
CreateYardModal,
CreateZoneModal,
WarehouseInventoryTable,
InventoryWorkbench,
WarehouseStatusBadge,
WarehouseTypeBadge,
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import {
useInspectInventory,
useMarkReadyForLoading,
useWarehouse,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
} from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import type { WarehouseInventoryItem, WarehouseYard, WarehouseZone } from '@/types/warehouse';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
@@ -56,7 +52,6 @@ function StatCard({ label, value }: { label: string; value: string }) {
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data: warehouse, isLoading } = useWarehouse(id);
const yardsQuery = useWarehouseYards(id);
@@ -71,9 +66,6 @@ export default function WarehouseDetailPage() {
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
const inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const [busyId, setBusyId] = useState<string | null>(null);
const yards = yardsQuery.data ?? [];
const yardOptions = useMemo(
@@ -81,30 +73,6 @@ export default function WarehouseDetailPage() {
[yards],
);
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
if (isLoading) {
return (
<Center mih="60vh">
@@ -338,18 +306,7 @@ export default function WarehouseDetailPage() {
{/* INVENTORY */}
<Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg">
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>

View File

@@ -1,67 +1,31 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Center,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Tabs,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
InventoryWorkbench,
ReceiveInventoryModal,
WarehouseInventoryTable,
inventoryStatusOptions,
} from '@/components/warehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import {
useDispatchInventory,
useInspectInventory,
useLoadInventory,
useMarkReadyForLoading,
useMoveInventory,
useReserveInventory,
useStoreInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const { toast } = useToast();
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status') as InventoryStatus | null;
const [filter, setFilter] = useState<InventoryFilter>({
status: initialStatus ?? undefined,
warehouseId: searchParams.get('warehouseId') ?? undefined,
yardId: searchParams.get('yardId') ?? undefined,
zoneId: searchParams.get('zoneId') ?? undefined,
});
const [search, setSearch] = useState(searchParams.get('search') ?? '');
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [moveDraft, setMoveDraft] = useState<{
warehouseId?: string;
yardId?: string;
zoneId?: string;
remarks?: string;
}>({});
const [busyId, setBusyId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
@@ -76,14 +40,6 @@ export default function WarehouseInventoryPage() {
const moveZonesQuery = useWarehouseZones(moveDraft.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const storeMutation = useStoreInventory();
const reserveMutation = useReserveInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const moveMutation = useMoveInventory();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
@@ -120,128 +76,6 @@ export default function WarehouseInventoryPage() {
setMoveDraft({});
};
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleStore = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await storeMutation.mutateAsync(item.id);
toast({ title: 'Inventory stored' });
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReserve = async (item: WarehouseInventoryItem) => {
if (item.status !== 'STORED') {
toast({ variant: 'destructive', title: 'Only STORED inventory can be reserved.' });
return;
}
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can reserve stored inventory.' });
return;
}
setBusyId(item.id);
try {
await reserveMutation.mutateAsync({ id: item.id, payload: { bookingId: item.bookingId } });
toast({ title: 'Inventory reserved' });
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleLoad = async (item: WarehouseInventoryItem) => {
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can be loaded.' });
return;
}
setBusyId(item.id);
try {
await loadMutation.mutateAsync(item.id);
toast({ title: 'Inventory loaded' });
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleDispatch = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await dispatchMutation.mutateAsync(item.id);
toast({ title: 'Inventory dispatched' });
} catch (error) {
toast({ variant: 'destructive', title: 'Dispatch failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const inventory = inventoryQuery.data ?? [];
const readyToLoad = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
(item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID'),
);
const pendingPayment = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
item.booking?.status !== 'PAID' &&
item.booking?.paymentStatus !== 'PAID',
);
const loadedInventory = inventory.filter((item) => item.status === 'LOADED');
const handleMove = async () => {
if (!moveItem || !moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId) return;
setBusyId(moveItem.id);
try {
await moveMutation.mutateAsync({
id: moveItem.id,
payload: {
warehouseId: moveDraft.warehouseId,
yardId: moveDraft.yardId,
zoneId: moveDraft.zoneId,
remarks: moveDraft.remarks?.trim() || undefined,
},
});
toast({ title: 'Inventory moved' });
closeMoveModal();
} catch (error) {
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
@@ -251,7 +85,7 @@ export default function WarehouseInventoryPage() {
<div>
<Title order={2}>Warehouse Inventory</Title>
<Text c="dimmed" size="sm">
Track received items and move them through inspection to loading.
Track received items through the storage, reservation, loading and dispatch lifecycle.
</Text>
</div>
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
@@ -310,23 +144,7 @@ export default function WarehouseInventoryPage() {
/>
</Group>
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventory}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>

View File

@@ -0,0 +1,238 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useCancelInvoice,
usePayInvoice,
useWarehouseInvoice,
useWarehouseInvoices,
} from '@/hooks/useWarehouses';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
CANCELLED: 'gray',
};
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
const invoices = data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return invoices;
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : filtered.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
<Eye size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
</Container>
);
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
const pay = usePayInvoice();
const cancel = useCancelInvoice();
const [payAmount, setPayAmount] = useState<number | ''>('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const handlePay = async () => {
if (!inv || !payAmount) return;
try {
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
toast({ title: 'Payment recorded' });
setPayAmount('');
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
}
};
const handleCancel = async () => {
if (!inv) return;
try {
await cancel.mutateAsync(inv.id);
toast({ title: 'Invoice cancelled' });
onClose();
} catch (e) {
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
}
};
return (
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
{isLoading || !inv ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
</Group>
<Table withRowBorders={false} verticalSpacing={4}>
<Table.Tbody>
{(inv.items ?? []).map((it) => (
<Table.Tr key={it.id}>
<Table.Td>
<Text size="sm">{it.description}</Text>
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
</Table.Td>
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider />
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
{(inv.payments ?? []).length > 0 && (
<>
<Divider label="Payment history" labelPosition="left" />
{(inv.payments ?? []).map((p, i) => (
<Group key={i} justify="space-between">
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
</Group>
))}
</>
)}
{canPay && (
<>
<Divider label="Record payment" labelPosition="left" />
<Group align="flex-end">
<NumberInput
label="Amount"
min={0}
value={payAmount}
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }}
/>
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
</Group>
</>
)}
<Group justify="flex-end" mt="sm">
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>
)}
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,285 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useAllocationRules,
useCreateAllocationRule,
useCreateFeeRule,
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
} from '@/hooks/useWarehouses';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
];
const TRADE = [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
];
const clean = (s: string) => s.trim() || undefined;
export default function WarehouseRulesPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="warehouse"
secondaryVariant="container"
title="Allocation & Fee Rules"
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
/>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="allocation">
<Tabs.List>
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="allocation" pt="md">
<AllocationRules />
</Tabs.Panel>
<Tabs.Panel value="fees" pt="md">
<FeeRules />
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
);
}
function AllocationRules() {
const { toast } = useToast();
const { data, isLoading } = useAllocationRules();
const create = useCreateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
priority: 100,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerStatus: '',
targetYardCode: '',
storageType: '',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
containerStatus: clean(form.containerStatus) ?? null,
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text>
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.priority}</Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
</Group>
<Group grow>
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
</Group>
<Group grow>
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}
function FeeRules() {
const { toast } = useToast();
const { data, isLoading } = useFeeRules();
const create = useCreateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
freeDays: 3,
ratePerDay: 0,
currency: 'USD',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim()) {
toast({ variant: 'destructive', title: 'Name is required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
freeDays: form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
isActive: true,
} as never);
toast({ title: 'Fee rule created' });
setOpen(false);
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text>
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.freeDays}</Table.Td>
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
</Group>
<Group grow>
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}