Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-04 05:54:26 +00:00
231 changed files with 13389 additions and 3301 deletions

View File

@@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
@@ -83,6 +84,8 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
@@ -120,6 +123,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -178,6 +182,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// Hidden for now — Shipment Requests nav item disabled.
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
@@ -328,7 +333,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
},
{
@@ -375,7 +380,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
},
],
@@ -567,6 +572,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
@@ -576,6 +582,7 @@ const App = () => {
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"
@@ -672,6 +679,7 @@ const App = () => {
</RequirePermission>
}
/>
{/* Hidden for now — Shipment Requests pages disabled.
<Route
path="shipment-requests"
element={
@@ -692,6 +700,7 @@ const App = () => {
</RequirePermission>
}
/>
*/}
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"
@@ -923,6 +932,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="vehicles/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<VehicleDetailPage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
@@ -931,6 +948,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="drivers/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<DriverDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={

View File

@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
isDelegate?: boolean;
parentPositionId?: string | null;
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
}
interface AuthEmployeeRecord {

View File

@@ -33,7 +33,6 @@ export interface ContainerAllocationTableProps {
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
@@ -43,7 +42,10 @@ export function ContainerAllocationTable({
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
queryFn: async () => {
const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" });
return res.data ?? [];
},
});
const vehicleOptions = useMemo(
@@ -85,13 +87,12 @@ export function ContainerAllocationTable({
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Group justify="center" p="xl">
<Loader size="sm" />
</Box>
</Group>
);
}

View File

@@ -1,164 +0,0 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface FirstMileContainerAllocationTableProps {
firstMileId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for first-mile pickups.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function FirstMileContainerAllocationTable({
firstMileId,
containers,
onSave,
}: FirstMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No free vehicles available. Free up or add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -1,164 +0,0 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
export interface LastMileContainerAllocationTableProps {
lastMileId: string;
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
lastMileId,
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No free vehicles available. Free up or add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { Zap, Clock } from "lucide-react";
import { Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
/** Detail-page actions: primary staff-action toolbar. */
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
return null;
}
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && (
<SectionCard icon={FileText} title="Documents">
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
Download contract
</Button>
</SectionCard>
)}
</Stack>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
export interface BookingContainerUnitsCardProps {
booking: BookingDetail;
}
interface FlatUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
typeLabel: string;
sizeFt?: number;
}
/**
* The physical container manifest: one row per container with its number, type,
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
* bookings — when a line has no units the card falls back to the aggregate
* type/qty/weight so it still renders something for plain bookings.
*/
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
const lines = booking.bookingContainers ?? [];
const units: FlatUnit[] = useMemo(
() =>
lines.flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber,
vgmTons: Number(u.vgmTons) || 0,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
sizeFt: line.containerType?.sizeFt,
})),
),
[lines],
);
// Container bookings only — bulk has no container manifest.
if (booking.freightType === "BULK" || lines.length === 0) return null;
const totalUnits = units.length;
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
return (
<SectionCard
icon={Boxes}
title="Containers"
subtitle={
totalUnits > 0
? "Each physical container with its number and weight"
: "Per-container numbers were not captured for this booking"
}
accent="teal"
extra={
totalUnits > 0 ? (
<Badge color="teal" variant="light" radius="sm">
{totalUnits} container{totalUnits === 1 ? "" : "s"}
</Badge>
) : (
<Badge color="gray" variant="light" radius="sm">
{lines.length} line{lines.length === 1 ? "" : "s"}
</Badge>
)
}
>
{totalUnits > 0 ? (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 40 }}>#</Table.Th>
<Table.Th>Container No.</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">Weight (VGM)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u, i) => (
<Table.Tr key={u.id}>
<Table.Td>
<Text size="sm" c="dimmed">
{i + 1}
</Text>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={26} radius="md" variant="light" color="teal">
<ContainerIcon size={15} />
</ThemeIcon>
<Text size="sm" fw={700} ff="monospace">
{u.containerNumber}
</Text>
{u.isReefer ? (
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
<Snowflake size={12} />
</ThemeIcon>
) : null}
{u.isHazardous ? (
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
<Flame size={12} />
</ThemeIcon>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm">{u.typeLabel}</Text>
{u.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{u.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
{u.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{u.vgmTons.toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
pt="sm"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="sm" fw={600} c="dimmed">
Total weight (VGM)
</Text>
<Text size="sm" fw={800} c="teal.7">
{totalVgm.toFixed(3)} t
</Text>
</Group>
</Stack>
) : (
// Fallback: no per-unit numbers — show the aggregate lines.
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
<Table.Th ta="right">Total VGM</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => {
const perUnit = Number(line.vgmPerUnitTons) || 0;
return (
<Table.Tr key={line.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{line.containerType?.label ?? line.containerType?.code ?? "—"}
</Text>
{line.containerType?.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{line.containerType.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>{line.quantity}</Table.Td>
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{(line.quantity * perUnit).toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}

View File

@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";

View File

@@ -4,9 +4,10 @@ import {
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
@@ -18,6 +19,7 @@ import {
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
@@ -25,6 +27,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
@@ -58,16 +61,36 @@ import {
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: number | string;
/** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
hazardous: boolean;
reefer: boolean;
}
interface ContainerLineDraft {
containerSize: string;
hazardousQuantity: number | string;
reeferQuantity: number | string;
units: UnitDraft[];
}
@@ -80,7 +103,13 @@ interface BulkLineDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
hazardous: false,
reefer: false,
};
}
function bulkUnitOfMeasure(
@@ -106,6 +135,33 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
@@ -152,11 +208,13 @@ export default function GlCreateBookingForm() {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "0",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
// The request carries counts; pre-toggle the first N units so GL sees
// the customer's declared hazardous/reefer split and can adjust it.
units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
...emptyUnit(),
hazardous: i < Number(c.hazardousQuantity ?? 0),
reefer: i < Number(c.reeferQuantity ?? 0),
})),
})),
);
} else if (lines.bulk) {
@@ -182,8 +240,6 @@ export default function GlCreateBookingForm() {
setContainerLines(
containerSizes.map((size) => ({
containerSize: size,
hazardousQuantity: "0",
reeferQuantity: "0",
units: [emptyUnit()],
})),
);
@@ -214,8 +270,8 @@ export default function GlCreateBookingForm() {
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
@@ -314,12 +370,16 @@ export default function GlCreateBookingForm() {
);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract) return;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
* exactly what gets booked. */
const buildPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!scheduledDate || !contract) return null;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -333,12 +393,10 @@ export default function GlCreateBookingForm() {
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
...(l.reeferQuantity !== ""
? { reeferQuantity: Number(l.reeferQuantity) }
: {}),
// Counts are derived from the per-unit toggles — they can never
// exceed the line quantity.
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -361,6 +419,59 @@ export default function GlCreateBookingForm() {
}));
}
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge.
// Fired when the price modal opens; the modal falls back to the contract
// unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
});
const validation = validateShipmentMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? priceTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, priceTotal]);
const displayTotal = serverTotal ?? priceTotal;
const pairingErrors = validation?.pairingErrors ?? [];
const capacityErrors = validation?.capacityErrors ?? [];
const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => {
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
};
const handleSubmit = () => {
if (!contract || !windowOpen) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
if (capacityErrors.length > 0) return;
const payload = buildPayload();
if (!payload) return;
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
@@ -451,6 +562,33 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
@@ -521,7 +659,7 @@ export default function GlCreateBookingForm() {
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<Group gap={12} grow mb={12} align="flex-start">
<Group gap={12} mb={12} align="flex-start">
<NumberInput
label="Quantity *"
min={1}
@@ -529,37 +667,24 @@ export default function GlCreateBookingForm() {
onChange={(v) => syncUnits(lineIdx, Number(v) || 0)}
radius={10}
styles={fieldStyles}
w={160}
/>
{contract.isHazardous ? (
<NumberInput
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
onChange={(v) =>
patchLine(lineIdx, { hazardousQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
<Badge variant="light" color="red" radius="sm" mt={30}>
{line.units.filter((u) => u.hazardous).length} hazardous
</Badge>
) : null}
{contract.isReefer ? (
<NumberInput
label="Reefer qty"
min={0}
value={line.reeferQuantity}
onChange={(v) =>
patchLine(lineIdx, { reeferQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
<Badge variant="light" color="blue" radius="sm" mt={30}>
{line.units.filter((u) => u.reefer).length} refrigerated
</Badge>
) : null}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<Group key={unitIdx} gap={10} align="flex-start" wrap="nowrap">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
@@ -571,6 +696,7 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
@@ -583,6 +709,7 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<NumberInput
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
@@ -595,7 +722,52 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{/* Per-unit flags: toggle exactly the containers that are
hazardous / refrigerated; line counts derive from these. */}
{contract.isHazardous ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Hazardous
</Text>
) : null}
<Switch
color="red"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} hazardous`}
checked={unit.hazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
hazardous: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
{contract.isReefer ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Reefer
</Text>
) : null}
<Switch
color="blue"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} refrigerated`}
checked={unit.reefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
reefer: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
</Group>
))}
</Stack>
@@ -745,7 +917,7 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={() => setPriceOpen(true)}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
@@ -776,11 +948,89 @@ export default function GlCreateBookingForm() {
</Group>
}
>
{priceTotal ? (
{displayTotal ? (
<Stack gap="md">
{validateShipmentMutation.isPending && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{pairingErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs
differ by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{capacityErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers
to book this shipment.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds
limit {line.maxAllowedTons}t (+{line.excessTons}t
overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
An overweight surcharge applies (included in the total
below).
</Text>
</Stack>
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Stack gap={10}>
{priceTotal.lines.map((line, i) => (
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
@@ -788,16 +1038,16 @@ export default function GlCreateBookingForm() {
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {priceTotal.currency}
{line.amount.toLocaleString()} {displayTotal.currency}
</Text>
</Group>
))}
{priceTotal.lines.length === 0 && (
{displayTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
@@ -815,9 +1065,9 @@ export default function GlCreateBookingForm() {
Total
</Text>
<Text fw={800} fz={28}>
{priceTotal.total.toLocaleString()}{" "}
{displayTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{priceTotal.currency}
{displayTotal.currency}
</Text>
</Text>
</Group>
@@ -838,6 +1088,11 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
capacityErrors.length > 0
}
onClick={handleSubmit}
>
Confirm &amp; book
@@ -846,6 +1101,8 @@ export default function GlCreateBookingForm() {
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,332 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
CalendarClock,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: TZ,
});
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: TZ,
});
}
function windowLabel(w: StaffBookingWindow): string {
if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt,
)} EAT`;
}
if (w.windowOpensAt) {
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
}
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
* between refetches announces what comes next rather than the bare "Expired".
*/
function phaseCountdown(
w: StaffBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Opens in",
deadline: w.windowOpensAt,
expiredText: "Opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Closes in",
deadline: w.windowClosesAt,
expiredText: "Review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Doc review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Closing…",
}
: null;
default:
return null;
}
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: StaffBookingWindow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
}
function WindowCard({ w }: { w: StaffBookingWindow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const isImport = w.direction === "IMPORT";
return (
<Box
p="md"
style={{
borderRadius: 14,
height: "100%",
border: `1px solid ${
open
? "var(--mantine-color-edr-green-3)"
: "var(--mantine-color-gray-2)"
}`,
background: open
? "linear-gradient(160deg, var(--mantine-color-edr-green-0) 0%, #ffffff 85%)"
: "var(--mantine-color-body)",
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
transition: "border-color 150ms ease, box-shadow 150ms ease",
}}
>
<Stack gap={8} h="100%" justify="space-between">
<Box>
<Group justify="space-between" wrap="nowrap" gap={8}>
{w.direction ? (
<Badge
variant="light"
color={isImport ? "blue" : "teal"}
radius="sm"
size="sm"
>
{isImport ? "Import" : "Export"}
</Badge>
) : (
<span />
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text fz={15} fw={700} truncate>
{w.destination ?? "—"}
</Text>
</Group>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
Train {w.trainNumber}
</Text>
) : null}
<Group gap={6} wrap="nowrap" mt={8}>
<CalendarClock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
<Text fz={12} c="dimmed" truncate>
{windowLabel(w)}
</Text>
</Group>
{w.departureDate ? (
<Text fz={12} c="dimmed">
Departs {fmtDay(w.departureDate)}
</Text>
) : null}
</Box>
{cd ? (
<Box
px={10}
py={6}
style={{
borderRadius: 10,
background: open
? "rgba(10,111,77,0.08)"
: "var(--mantine-color-gray-0)",
}}
>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Stack>
</Box>
);
}
/**
* All announced booking windows (import cycles + export FCFS) across every lane,
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
* Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection() {
const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000,
}),
);
const [page, setPage] = useState(0);
const windows = useMemo(() => {
const rows = (data ?? []).filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
});
}, [data]);
const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE));
const safePage = Math.min(page, pageCount - 1);
const visible = windows.slice(
safePage * PER_PAGE,
safePage * PER_PAGE + PER_PAGE,
);
if (!isLoading && windows.length === 0) return null;
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Import and export booking windows across all lanes (EAT)
</Text>
</Box>
</Group>
{pageCount > 1 ? (
<Group gap={8} wrap="nowrap">
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Previous windows"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={18} />
</ActionIcon>
<Group gap={5} wrap="nowrap">
{Array.from({ length: pageCount }, (_, i) => (
<Box
key={i}
onClick={() => setPage(i)}
style={{
width: i === safePage ? 18 : 7,
height: 7,
borderRadius: 999,
cursor: "pointer",
background:
i === safePage
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-3)",
transition: "width 200ms ease, background 200ms ease",
}}
/>
))}
</Group>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Next windows"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRight size={18} />
</ActionIcon>
</Group>
) : null}
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={150} radius="md" />
))}
</SimpleGrid>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
)}
</Card>
);
}

View File

@@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null {
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Card heading. Defaults to "Documents". */
title?: string;
/** Message shown when there are no files. */
emptyText?: string;
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
@@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps {
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
title = "Documents",
emptyText = "No documents attached to this contract.",
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
title={title}
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
{emptyText}
</Text>
) : (
<Stack gap="xs">

View File

@@ -32,7 +32,7 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadTransportDocument(bookingId, file);
await contractsService.uploadTransportDocument(bookingId, { transportDocument: file });
toast.success("Transport document uploaded");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
@@ -12,7 +14,6 @@ import {
Text,
Textarea,
TextInput,
ActionIcon,
} from "@mantine/core";
import {
@@ -20,6 +21,10 @@ import {
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import {
verifaydaService,
type FaydaCallbackMessage,
} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
@@ -31,8 +36,32 @@ export interface FleetFormDialogProps {
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
/**
* Show a "Verify with Fayda" step: opens the eSignet popup and prefills
* firstName/lastName/email/phoneNumber/dateOfBirth from the verified
* identity, stamping faydaVerified + faydaSub on the payload.
*/
verifyWithFayda?: boolean;
}
// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase
// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering
// blank. Unknown/empty values fall through to undefined (field left untouched).
const normalizeGender = (raw?: string): string | undefined => {
const up = (raw ?? "").trim().toUpperCase();
if (up === "MALE" || up === "M") return "MALE";
if (up === "FEMALE" || up === "F") return "FEMALE";
return up ? "OTHER" : undefined;
};
// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input
// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars
// so the DOB field prefills instead of silently staying blank.
const normalizeBirthdate = (raw?: string): string | undefined => {
const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined;
};
const buildInitialValues = (
fields: FleetFormFieldDef[],
emptyValues: Record<string, unknown>,
@@ -72,9 +101,12 @@ const FleetFormDialog = ({
isSubmitting,
selectOptionsLoading,
onSubmit,
verifyWithFayda,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [faydaLoading, setFaydaLoading] = useState(false);
const [faydaError, setFaydaError] = useState<string | null>(null);
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
@@ -87,10 +119,88 @@ const FleetFormDialog = ({
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
setFaydaError(null);
setFaydaLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
if (!open || !verifyWithFayda) return;
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== "fayda-callback") return;
if (event.data.error) {
setFaydaLoading(false);
setFaydaError(event.data.errorDescription ?? event.data.error);
return;
}
if (!event.data.code || !event.data.state) return;
try {
const result = await verifaydaService.complete(event.data.code, event.data.state);
if (!result.verified) {
setFaydaError("Identity could not be verified");
return;
}
const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
const [firstName, ...rest] = nameParts;
const gender = normalizeGender(result.gender);
const dateOfBirth = normalizeBirthdate(result.birthdate);
setValues((current) => ({
...current,
...(firstName ? { firstName } : {}),
...(rest.length ? { lastName: rest.join(" ") } : {}),
...(result.email ? { email: result.email } : {}),
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
...(dateOfBirth ? { dateOfBirth } : {}),
...(gender ? { gender } : {}),
faydaVerified: true,
...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
}));
setFaydaError(null);
} catch (err) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Verification failed");
setFaydaError(message);
} finally {
setFaydaLoading(false);
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, verifyWithFayda]);
const handleFaydaVerify = async () => {
setFaydaError(null);
setFaydaLoading(true);
try {
const { authorizationUrl } = await verifaydaService.start();
const popup = window.open(
authorizationUrl,
"fayda-verify",
"width=480,height=760,noopener=no",
);
if (!popup) {
setFaydaLoading(false);
setFaydaError("Pop-up blocked — allow pop-ups for this site and retry.");
}
// Loading stays on until the popup posts back; reopening the dialog resets it.
} catch (err) {
setFaydaLoading(false);
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Could not start verification");
setFaydaError(message);
}
};
const faydaVerified = values.faydaVerified === true;
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
@@ -127,6 +237,12 @@ const FleetFormDialog = ({
const date = new Date(stringValue + "T00:00:00Z");
if (isNaN(date.getTime())) {
next[field.name] = `${field.label} is not a valid date`;
} else if (field.dateBound === "future") {
const startOfToday = new Date();
startOfToday.setUTCHours(0, 0, 0, 0);
if (date <= startOfToday) {
next[field.name] = `${field.label} must be in the future`;
}
} else if (date > new Date()) {
next[field.name] = `${field.label} cannot be in the future`;
}
@@ -147,6 +263,12 @@ const FleetFormDialog = ({
}, [fields]);
const handleSubmit = () => {
// Hard gate: a driver record cannot be saved until its identity is verified
// with Fayda. Mirrored server-side in DriversService.
if (verifyWithFayda && !faydaVerified) {
setFaydaError("Verify the driver's identity with Fayda before saving.");
return;
}
if (!validate()) return;
const payload = Object.fromEntries(
Object.entries(values)
@@ -167,6 +289,9 @@ const FleetFormDialog = ({
const renderField = (field: FleetFormFieldDef) => {
const value = values[field.name];
const error = errors[field.name];
// Fayda-owned identity fields (name/email/phone/DOB/gender) are populated
// only by verification and never hand-edited.
const isDisabled = Boolean(field.disabled || field.faydaLocked);
if (field.type === "select") {
return (
@@ -186,7 +311,7 @@ const FleetFormDialog = ({
}
error={error}
searchable
disabled={selectOptionsLoading}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
@@ -216,7 +341,7 @@ const FleetFormDialog = ({
error={error}
searchable
clearable
disabled={selectOptionsLoading}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
@@ -240,7 +365,7 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
/>
);
}
@@ -260,7 +385,7 @@ const FleetFormDialog = ({
}
error={error}
minRows={3}
disabled={field.disabled}
disabled={isDisabled}
/>
);
}
@@ -280,13 +405,8 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
description={field.description || "Select a date"}
rightSection={
<ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} />
</ActionIcon>
}
size="sm"
radius="md"
styles={{
@@ -318,7 +438,7 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
/>
);
};
@@ -333,6 +453,40 @@ const FleetFormDialog = ({
centered
>
<Stack gap="md">
{verifyWithFayda && (
<Group justify="space-between" wrap="nowrap">
{faydaVerified ? (
<Badge
color="green"
variant="light"
size="lg"
leftSection={<ShieldCheck size={14} />}
>
Identity verified with Fayda
</Badge>
) : (
<Text size="sm" c="dimmed">
Identity must be verified with Fayda before this driver can be
saved.
</Text>
)}
<Button
variant={faydaVerified ? "default" : "light"}
color="edr-green"
size="xs"
leftSection={<ShieldCheck size={14} />}
loading={faydaLoading}
onClick={handleFaydaVerify}
>
{faydaVerified ? "Re-verify" : "Verify with Fayda"}
</Button>
</Group>
)}
{verifyWithFayda && faydaError && (
<Alert color="red" variant="light">
{faydaError}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{shortFields.map(renderField)}
</SimpleGrid>
@@ -345,6 +499,7 @@ const FleetFormDialog = ({
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
disabled={verifyWithFayda && !faydaVerified}
>
Save
</Button>

View File

@@ -0,0 +1,207 @@
import { Center, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDot,
Route,
Truck,
UserCheck,
UserMinus,
UserPlus,
} from "lucide-react";
import {
fleetHistoryService,
type FleetHistoryEvent,
} from "@/services/fleet-history.service";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetHistoryModalProps {
opened: boolean;
onClose: () => void;
entity: "driver" | "vehicle";
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => {
const r = asObj(record);
if (entity === "vehicle") {
return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim();
}
return `Driver history — ${[r.firstName, r.lastName]
.filter(Boolean)
.join(" ")}`.trim();
};
const mileLabel = (e: FleetHistoryEvent) =>
e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile";
const arrow = (from?: string | null, to?: string | null) =>
`${from ?? "—"}${to ?? "—"}`;
const metaStr = (e: FleetHistoryEvent, key: string) => {
const v = e.metadata?.[key];
return typeof v === "string" && v ? v : null;
};
function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") {
const vehiclePlate = metaStr(e, "vehiclePlate");
const driverName = metaStr(e, "driverName") ?? (e.label || null);
const bookingRef = metaStr(e, "bookingRef");
// Compose the detail line with whatever the current view doesn't already
// know: on a driver's timeline show which vehicle; always show the booking.
const detail = (extra?: string) =>
[
entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "",
bookingRef ? `Booking ${bookingRef}` : "",
extra ?? "",
]
.filter(Boolean)
.join(" · ");
switch (e.eventType) {
case "DRIVER_REGISTERED":
return {
icon: <UserCheck size={14} />,
title: "Driver registered",
text: e.toValue ? `Status: ${e.toValue}` : "",
};
case "VEHICLE_REGISTERED":
return {
icon: <Truck size={14} />,
title: "Vehicle registered",
text: e.toValue ? `Availability: ${e.toValue}` : "",
};
case "DRIVER_ASSIGNED":
return {
icon: <UserPlus size={14} />,
title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "DRIVER_UNASSIGNED":
return {
icon: <UserMinus size={14} />,
title:
entity === "vehicle"
? "Driver unassigned"
: "Unassigned from vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "VEHICLE_STATUS_CHANGED":
return {
icon: <CircleDot size={14} />,
title: "Status changed",
text: arrow(e.fromValue, e.toValue),
};
case "VEHICLE_AVAILABILITY_CHANGED":
return {
icon: <Activity size={14} />,
title: `Marked ${e.toValue ?? ""}`.trim(),
text: e.fromValue ? arrow(e.fromValue, e.toValue) : "",
};
case "MILE_VEHICLE_ASSIGNED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle assigned`,
text: detail(e.label ? `Status: ${e.label}` : ""),
};
case "MILE_VEHICLE_RELEASED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle released`,
text: detail(),
};
case "MILE_STATUS_CHANGED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)} status`,
text: detail(arrow(e.fromValue, e.toValue)),
};
default:
return { icon: <CircleDot size={14} />, title: e.eventType, text: "" };
}
}
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const FleetHistoryModal = ({
opened,
onClose,
entity,
record,
}: FleetHistoryModalProps) => {
const id = asObj(record).id ? String(asObj(record).id) : "";
const { data, isLoading } = useQuery({
queryKey: ["fleet-history", entity, id],
queryFn: () =>
entity === "vehicle"
? fleetHistoryService.vehicle(id)
: fleetHistoryService.driver(id),
enabled: opened && Boolean(id),
});
const events = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{titleFor(entity, record)}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No history recorded yet. Activity appears here as this{" "}
{entity} is assigned, reassigned, or its status changes.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const d = describe(e, entity);
return (
<Timeline.Item key={e.id} bullet={d.icon} title={d.title}>
{d.text && (
<Text size="sm" c="dimmed">
{d.text}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.createdAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default FleetHistoryModal;

View File

@@ -1,4 +1,4 @@
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +11,8 @@ export interface FleetRecordActionsProps {
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
onViewDetail?: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
@@ -20,12 +22,18 @@ const FleetRecordActions = ({
onEdit,
onRemove,
onAssignDriver,
onHistory,
onViewDetail,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const showViewDetail = Boolean(onViewDetail);
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
@@ -57,6 +65,22 @@ const FleetRecordActions = ({
>
Edit
</MenuItem>
{showViewDetail ? (
<MenuItem
onClick={() => onViewDetail?.(record)}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View detail
</MenuItem>
) : null}
{showHistory ? (
<MenuItem
onClick={() => onHistory?.(record)}
leftSection={<History size={14} strokeWidth={2} />}
>
History
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}
@@ -101,6 +125,14 @@ const FleetRecordActions = ({
>
Edit
</MenuItem>
{showViewDetail ? (
<MenuItem
onClick={() => onViewDetail?.(record)}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View detail
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}

View File

@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
export type FleetColumnFormat = ColumnFormat | "statusBadge";
export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge";
const optionLabelMap = new Map<string, Map<string, string>>();
@@ -20,6 +20,16 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
if (format === "verifiedBadge") {
return value === true ? (
<Badge variant="light" color="green" size="sm" radius="md">
Verified
</Badge>
) : (
<Text size="sm" c="dimmed"></Text>
);
}
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {

View File

@@ -0,0 +1,93 @@
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import { Check } from "lucide-react";
/** One stage of the last-mile delivery workflow. */
export interface LastMileStepState {
label: string;
done: boolean;
active: boolean;
/** Optional stamp/value shown next to the step (plate, time, distance…). */
detail?: string | null;
}
/**
* Compact 6-dot progress bar for a table row — filled = done, ringed = current,
* hollow = pending. Hover a dot for its label + stamp.
*/
export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) {
return (
<Group gap={4} wrap="nowrap">
{steps.map((s, i) => {
const color = s.done
? "var(--mantine-color-green-6)"
: s.active
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-gray-4)";
return (
<Tooltip
key={i}
withArrow
label={`${s.label}${s.detail ? ` · ${s.detail}` : ""}`}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.done ? color : "transparent",
border: `2px solid ${color}`,
boxShadow: s.active
? "0 0 0 2px var(--mantine-color-blue-1)"
: undefined,
display: "inline-block",
flex: "0 0 auto",
}}
/>
</Tooltip>
);
})}
</Group>
);
}
/**
* Vertical stepper for the detail view — completed steps bulleted + green, the
* current step highlighted, each showing its stamp/value when known.
*/
export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) {
const activeIndex = steps.findIndex((s) => s.active);
// Timeline highlights items with index < `active`; count of done steps drives it.
const doneCount = steps.filter((s) => s.done).length;
return (
<Timeline
active={activeIndex === -1 ? steps.length : doneCount}
bulletSize={22}
lineWidth={2}
color="green"
>
{steps.map((s, i) => (
<Timeline.Item
key={i}
bullet={s.done ? <Check size={12} /> : undefined}
title={
<Text size="sm" fw={s.active ? 600 : 500} c={s.active ? "blue" : undefined}>
{s.label}
</Text>
}
lineVariant={s.done ? "solid" : "dashed"}
>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{s.done ? "Done" : s.active ? "Current step" : "Pending"}
</Text>
{s.detail && (
<Text size="xs" c="dimmed">
{s.detail}
</Text>
)}
</Stack>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
setValues((current) => ({ ...current, [name]: value }));
setValues((current) => {
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new
// allowed set instead of submitting a stale, rejected unit.
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
next.rateUnit = "";
}
return next;
});
};
const handleSubmit = (event: React.FormEvent) => {
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
return (
<Select
key={field.name}
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
data={(field.options ?? [])
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({
label: opt.label,

View File

@@ -0,0 +1,123 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Group, NumberInput, Select, Stack } from "@mantine/core";
export type DurationUnit = "minutes" | "hours" | "days";
const UNIT_MINUTES: Record<DurationUnit, number> = {
minutes: 1,
hours: 60,
days: 1440,
};
const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
{ value: "minutes", label: "min" },
{ value: "hours", label: "hr" },
{ value: "days", label: "day" },
];
/** Convert a value expressed in `from` units to `to` units. */
function convert(value: number, from: DurationUnit, to: DurationUnit): number {
return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to];
}
/** Pick the largest unit that keeps a value a clean-ish whole number, so a
* stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */
function bestDisplayUnit(minutes: number): DurationUnit {
if (minutes <= 0) return "minutes";
if (minutes % 1440 === 0) return "days";
if (minutes % 60 === 0) return "hours";
return "minutes";
}
export interface DurationFieldProps {
label: string;
description?: string;
/** Current value, expressed in `nativeUnit` (what the API/DB stores). */
value: number | string;
/** The unit the parent stores/sends. The field converts to this on change. */
nativeUnit: DurationUnit;
/** Called with the value converted back to `nativeUnit` (or "" when blank). */
onChange: (nativeValue: number | "") => void;
/** Smallest allowed value, in `nativeUnit`. */
min?: number;
disabled?: boolean;
}
export default function DurationField({
label,
description,
value,
nativeUnit,
onChange,
min,
disabled,
}: DurationFieldProps) {
const nativeMinutes = useMemo(() => {
const num = value === "" || value == null ? NaN : Number(value);
return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN;
}, [value, nativeUnit]);
// Display unit is user-driven; seed it from the incoming value once.
const [unit, setUnit] = useState<DurationUnit>(() =>
Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit,
);
// The value usually arrives async (after the initial "" render), so the
// useState seed above runs before it exists. Re-pick the friendliest display
// unit the first time a real value shows up — but never again, so the user's
// manual unit choice sticks.
const seeded = useRef(false);
useEffect(() => {
if (!seeded.current && Number.isFinite(nativeMinutes)) {
seeded.current = true;
setUnit(bestDisplayUnit(nativeMinutes));
}
}, [nativeMinutes]);
const displayValue: number | "" = Number.isFinite(nativeMinutes)
? Number(convert(nativeMinutes, "minutes", unit).toFixed(4))
: "";
const emitNative = (display: number | "", displayUnit: DurationUnit) => {
if (display === "" || !Number.isFinite(Number(display))) {
onChange("");
return;
}
const native = convert(Number(display), displayUnit, nativeUnit);
onChange(Number(native.toFixed(6)));
};
return (
<Stack gap={4}>
<Group gap="xs" align="flex-end" wrap="nowrap">
<NumberInput
label={label}
description={description}
value={displayValue}
onChange={(v) =>
emitNative(v === "" ? "" : Number(v), unit)
}
clampBehavior="none"
allowDecimal
min={min != null ? convert(min, nativeUnit, unit) : 0}
disabled={disabled}
style={{ flex: 1 }}
/>
<Select
aria-label={`${label} unit`}
data={UNIT_OPTIONS}
value={unit}
onChange={(next) => {
if (!next) return;
// Only the display unit changes; the stored native value stays put.
// displayValue re-derives from it on the next render.
setUnit(next as DurationUnit);
}}
allowDeselect={false}
disabled={disabled}
w={90}
/>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,172 @@
import { useMemo, useState } from "react";
import { PackageCheck } from "lucide-react";
import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type {
ImportLoadingBooking,
ImportLoadingBookingsResponse,
LoadingStatus,
} from "@/types/trainScheduling";
function ImportLoadingBookingRow({
booking,
selected,
onToggle,
}: {
booking: ImportLoadingBooking;
selected: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<PackageCheck size={14} />
<Text fw={600} size="sm">
{booking.reference ?? booking.id}
</Text>
<Badge
variant="light"
size="xs"
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{booking.loadingStatus}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{booking.customer ?? "Unknown customer"}
</Text>
<Text size="xs" c="dimmed">
{booking.weightTons}T
</Text>
</Stack>
</Group>
);
}
export function ImportLoadingConfirmationPanel({
scheduleId,
items,
isLoading,
}: {
scheduleId: string;
items: ImportLoadingBooking[];
isLoading?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const queryClient = useQueryClient();
const updateStatus = useMutation<
ImportLoadingBookingsResponse,
Error,
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus }
>({
...api.trainScheduling.updateImportLoadingStatus.mutationOptions(),
onSuccess: () => {
setSelectedIds([]);
queryClient.invalidateQueries({
queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }),
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Could not update loading status");
},
});
const toggle = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const allIds = useMemo(() => items.map((b) => b.id), [items]);
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading import bookings
</Text>
</Group>
);
}
if (!items.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No paid import bookings with wagons allocated on this schedule
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Import bookings ({items.length})
</Text>
<Group gap="sm">
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
Clear
</Button>
</Group>
</Group>
<Stack gap="sm">
{items.map((booking) => (
<ImportLoadingBookingRow
key={booking.id}
booking={booking}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
<Group gap="sm">
<Button
color="edr-green"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
}
>
Mark loaded
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
}
>
Mark unloaded
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,618 @@
import { useMemo, useState } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Box,
Button,
Group,
Modal,
Paper,
Progress,
ScrollArea,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeftRight,
ArrowRight,
CheckCircle2,
Inbox,
PackageCheck,
Repeat,
Train,
Weight,
X,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
} from "@/types/trainScheduling";
interface ScheduleWorkspacePanelProps {
schedule: TrainScheduleDetail;
/** Refetch the schedule detail after a mutation so both panels refresh. */
onChanged: () => void;
}
const GREEN = "var(--mantine-color-edr-green-6)";
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
function apiErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const violations = data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join(", ");
if (typeof data?.message === "string") return data.message;
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
}
return fallback;
}
/**
* Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
): { label: string; deadline: string } | null {
switch (schedule.windowPhase) {
case "OPEN":
return schedule.windowClosesAt
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
: null;
default:
return null;
}
}
/** Cargo weight already allocated to this train (sum of on-train bookings). */
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),
0,
);
}
/** Max pull weight across all locomotives on the set (0 when unknown). */
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;
if (!set) return 0;
const locos =
set.locomotives && set.locomotives.length > 0
? set.locomotives
: set.locomotive
? [set.locomotive]
: [];
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
}
export function ScheduleWorkspacePanel({
schedule,
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
? schedule.freightType
: undefined;
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
// yet linked to any schedule (same filter the auto-batch uses).
const poolQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: {
filters: {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
trainScheduleId: schedule.id,
},
freightType,
},
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
}),
);
const onTrainIds = useMemo(
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
[schedule.bookings],
);
const pool: EligibleContainerBooking[] = useMemo(
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
[poolQuery.data, onTrainIds],
);
const onTrain = schedule.bookings ?? [];
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
originYardId: schedule.originStation?.id,
destinationYardId: schedule.destinationStation?.id,
},
enabled: Boolean(
schedule.originStation?.id && schedule.destinationStation?.id,
),
}),
);
const moveOptions = useMemo(
() =>
(targets ?? [])
.filter((s) => s.id !== schedule.id)
.map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
})),
[targets, schedule.id],
);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
.mutateAsync({
id: schedule.id,
freightType,
payload: {
bookingIds: [...onTrainIds, bookingId],
forceAssign: true,
},
})
.then(() => {
toast({
title: `${ref} added to train`,
description: wouldOverfill
? "Force-added past the pull-weight limit — review capacity."
: "Wagons auto-pinned.",
variant: wouldOverfill ? "destructive" : undefined,
});
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not add booking",
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
variant: "destructive",
}),
);
};
const removeFromTrain = (bookingId: string, ref: string) => {
unassign
.mutateAsync({ id: schedule.id, bookingId })
.then(() => {
toast({ title: `${ref} removed from train` });
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not remove booking",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
.then(() => {
toast({ title: "Booking reassigned to another train" });
setMoveBookingId(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not reassign booking",
description: apiErrorMessage(error, "Target train may be closed or full."),
variant: "destructive",
}),
);
};
return (
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Header + capacity meter */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<PackageCheck size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
</Text>
</div>
</Group>
<Box miw={240} style={{ flex: "0 1 320px" }}>
<Group justify="space-between" mb={4} gap={4}>
<Group gap={6} align="center">
<Weight size={14} color={over ? "#B42318" : undefined} />
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
</Text>
</Group>
{over ? (
<Badge color="red" variant="light" size="sm" radius="sm">
Over capacity
</Badge>
) : (
<Text size="xs" c="dimmed">
{capacity > 0 ? `${pct}%` : "—"}
</Text>
)}
</Group>
<Progress
value={capacity > 0 ? pct : 0}
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
radius="xl"
size="md"
/>
</Box>
</Group>
{(() => {
const cd = phaseCountdown(schedule);
return cd ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
</Group>
) : null;
})()}
{over ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This train is loaded beyond its locomotive pull weight. Force-adds are
allowed, but review before dispatch.
</Text>
</Group>
) : null}
{locked ? (
<Text size="sm" c="dimmed">
This train is {schedule.status.toLowerCase()} bookings can no longer be
changed.
</Text>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
key={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
) : null
}
/>
))}
</PanelColumn>
{/* On train */}
<PanelColumn
title="On this train"
hint="Allocated bookings"
count={onTrain.length}
accent="#0EA371"
emptyIcon={Train}
emptyText="No bookings allocated yet. Add one from the pool."
>
{onTrain.map((b) => (
<BookingCard
key={b.id}
reference={b.reference ?? b.id.slice(0, 8)}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
variant="subtle"
color="orange"
radius="md"
leftSection={<Repeat size={13} />}
onClick={() => {
setMoveBookingId(b.id);
setMoveTarget(null);
}}
>
Move
</Button>
</Tooltip>
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<X size={13} />}
loading={unassign.isPending}
onClick={() =>
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
}
>
Remove
</Button>
</Tooltip>
</Group>
) : null
}
/>
))}
</PanelColumn>
</Group>
</Stack>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
onClose={() => setMoveBookingId(null)}
title={
<Group gap={8}>
<ArrowLeftRight size={18} />
<Text fw={700}>Reassign booking to another train</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Select
label="Target train (same route, open window)"
placeholder="Select an open schedule"
data={moveOptions}
value={moveTarget}
onChange={setMoveTarget}
searchable
nothingFoundMessage="No other open schedules on this route"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMoveBookingId(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!moveTarget}
loading={moveSchedule.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doMove}
>
Reassign
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function PanelColumn({
title,
hint,
count,
accent,
loading,
emptyIcon: EmptyIcon,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
accent: string;
loading?: boolean;
emptyIcon: typeof Inbox;
emptyText: string;
children: React.ReactNode;
}) {
const isEmpty = !loading && count === 0;
return (
<Paper
radius="lg"
withBorder
p="md"
miw={280}
style={{
flex: 1,
borderColor: "var(--mantine-color-gray-2)",
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Group gap={8} align="center">
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
<Text fw={700} size="sm">
{title}
</Text>
<Badge variant="light" color="gray" radius="sm" size="sm">
{count}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Group>
{isEmpty ? (
<Stack align="center" gap={6} py={32}>
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="dimmed" ta="center" maw={220}>
{emptyText}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={420} type="hover">
<Stack gap={8} pr={4}>
{loading ? (
<Text size="xs" c="dimmed" py="md" ta="center">
Loading
</Text>
) : (
children
)}
</Stack>
</ScrollArea.Autosize>
)}
</Paper>
);
}
function BookingCard({
reference,
customer,
weightTons,
status,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
right?: React.ReactNode;
}) {
return (
<Paper
radius="md"
withBorder
p="sm"
style={{
borderColor: "var(--mantine-color-gray-2)",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = GREEN;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{customer ?? "—"}
</Text>
{weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
</Group>
</Paper>
);
}

View File

@@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
interface CreateWarehouseModalProps {
opened: boolean;
@@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"

View File

@@ -48,6 +48,9 @@ export const formatDate = (value: string | null | undefined) => {
});
};
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;

View File

@@ -109,6 +109,8 @@ export const QUERY_KEYS = {
["train-scheduling", "unassigned", id] as const,
compositionRemovals: (id: string) =>
["train-scheduling", "removals", id] as const,
importLoadingBookings: (id: string) =>
["train-scheduling", "import-loading-bookings", id] as const,
},
FLEET: {

View File

@@ -201,6 +201,7 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
@@ -281,11 +282,12 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/run-allocation`,
DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>
@@ -293,6 +295,7 @@ export const URL_CONSTANTS = {
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules",
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
PREVIEW: "/train-scheduling/preview",
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/assign-bookings`,
@@ -322,6 +325,10 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
FileSignature,
MessageSquareWarning,
Play,
ShieldCheck,
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
primary: true,
};
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
@@ -340,22 +316,14 @@ export function getBookingActions(
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
actions = [CANCEL_ACTION];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
// Contract view/sign/executed buttons intentionally removed from the
// booking-request page.
actions = [];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":

View File

@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return {
id: booking.id,
reference: booking.reference,
contractReference: booking.contractReference ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")

View File

@@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) {
note?: string;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),
onError: (error) => {
toast.error(parseApiError(error, "Failed to review operation request"));
// The transition may have committed even when the response errored (e.g.
// a post-accept step failed). Refetch so the UI shows the true state
// instead of requiring a manual refresh.
void invalidateBookingDetail(qc, bookingId);
},
});
const approveStep = useMutation({

View File

@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
});
/**
* Active wagon-type options for the cargo-type / container-type "Wagon type"
* picker. The FK the selection sets drives train-scheduling wagon resolution.
*/
export const useWagonTypeOptions = (enabled = true) =>
useQuery({
...api.wagonTypes.list.queryOptions(),
enabled,
select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
rows
.filter((wt) => wt.isActive !== false)
.map((wt) => ({
label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
value: wt.id,
})),
});
const LIVE_RATE_PAGE_SIZE = 500;
export const useLiveRateOptions = (enabled = true) =>

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import toast from 'react-hot-toast';
interface ToastOptions {
@@ -8,7 +9,9 @@ interface ToastOptions {
}
export function useToast() {
const showToast = (options: ToastOptions) => {
// Stable identity so callers can safely list `toast` in effect/callback deps
// without re-firing on every render.
const showToast = useCallback((options: ToastOptions) => {
const { title, description, variant = 'default', duration = 3000 } = options;
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
@@ -18,7 +21,7 @@ export function useToast() {
} else {
toast.success(message, { duration });
}
};
}, []);
return { toast: showToast };
}

View File

@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
return [...keys];
}
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
/**
* Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
* Tolerates IAM payload shape variants: the key flat on the employee position,
* nested under `position.key`, or the GL modeled as a role instead.
*/
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
if (pos.key) keys.add(pos.key);
if (pos.position?.key) keys.add(pos.position.key);
}
}
for (const role of user.roles ?? []) {
if (role.key) keys.add(role.key);
}
return [...keys];
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Center, Loader, Stack, Text } from "@mantine/core";
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
/**
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
* http://localhost:5183/callback). Runs inside the verification popup:
* relays ?code&state (or ?error) to the window that opened it via
* postMessage, then closes itself. The opener performs the /complete call
* so the single-use session is only consumed once, in one place.
*/
const FaydaCallbackPage = () => {
const [standalone, setStandalone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const message: FaydaCallbackMessage = {
type: "fayda-callback",
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
error: params.get("error") ?? undefined,
errorDescription: params.get("error_description") ?? undefined,
};
if (window.opener && window.opener !== window) {
(window.opener as Window).postMessage(message, window.location.origin);
window.close();
} else {
// Opened as a full-page redirect instead of a popup — nothing to relay to.
setStandalone(true);
}
}, []);
return (
<Center h="100vh">
<Stack align="center" gap="sm">
{standalone ? (
<>
<Text fw={600}>Verification window lost its parent page</Text>
<Text size="sm" c="dimmed">
Close this tab and restart the verification from the form.
</Text>
</>
) : (
<>
<Loader size="sm" />
<Text size="sm" c="dimmed">Completing Fayda verification</Text>
</>
)}
</Stack>
</Center>
);
};
export default FaydaCallbackPage;

View File

@@ -18,8 +18,8 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { ContainerAllocationTable } from "@/components/ContainerAllocationTable";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
@@ -160,9 +160,9 @@ const BookingDetailPage = () => {
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
onSave={async (allocations) => {
await allocateMutation.mutateAsync({ allocations });
}}
/>
<BookingApprovalCard
steps={approvalSteps}

View File

@@ -1,7 +1,6 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Milestone,
@@ -36,31 +35,19 @@ import {
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
ContractOrdersPanel,
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 type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
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 }>();
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
} = 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 (
<PageContainer>
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
@@ -351,15 +301,10 @@ function OverviewPanel({
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}

View File

@@ -4,6 +4,7 @@ import {
Button,
Card,
Group,
Select,
Stack,
Tabs,
Text,
@@ -30,17 +31,12 @@ import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import {
useBookingDetail,
@@ -57,11 +53,29 @@ import {
type ColumnDef,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
if (!match?.statuses?.length) return undefined;
return match.statuses.join(",");
}
/** The two booking-kind tabs: one-time vs general-contract bookings. */
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
{ value: "ONE_TIME", label: "One-time booking" },
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
);
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
@@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string {
});
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter selects (each nullable = "all").
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
@@ -93,47 +109,26 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const isOperationsTab = activeTab === "operations";
const filter: BookingListFilter = useMemo(() => {
if (isOperationsTab) {
if (operationsSubTab === "ready") {
return {
page: 1,
pageSize: 100,
statuses: "PAID",
assignedToSchedule: "false",
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
};
}
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "SCHEDULED,DISPATCHED",
sortBy: "scheduledDate",
sortOrder: "ASC",
tab: activeTab,
};
}
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
...(statusFilter ? { statuses: statusFilter } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
};
}, [
isOperationsTab,
operationsSubTab,
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
kindTab,
statusFilter,
directionFilter,
freightTypeFilter,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
@@ -154,7 +149,8 @@ export default function BookingRequestsPage() {
return items.filter(
(b) =>
b.reference.toLowerCase().includes(q) ||
b.customerLabel.toLowerCase().includes(q),
b.customerLabel.toLowerCase().includes(q) ||
(b.contractReference?.toLowerCase().includes(q) ?? false),
);
}, [data?.items, query]);
@@ -171,18 +167,6 @@ export default function BookingRequestsPage() {
void refetchSummary();
}, [refetch, refetchSummary]);
const handleAllocateFromQueue = useCallback(
(ids: string[]) => {
const selected = rows.filter((b) => ids.includes(b.id));
const sorted = [...selected].sort(
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
);
setAllocateIds(sorted.map((b) => b.id));
setAllocateOpen(true);
},
[rows],
);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
@@ -213,6 +197,22 @@ export default function BookingRequestsPage() {
);
},
},
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const ref = row.original.contractReference;
return (
<div className="py-1">
{ref ? (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -356,6 +356,8 @@ export default function BookingRequestsPage() {
]}
/>
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
old BookingStatusTabs is commented out — status is now a filter select.
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
@@ -364,73 +366,97 @@ export default function BookingRequestsPage() {
}}
counts={tabCounts}
/>
*/}
<Tabs
value={kindTab}
onChange={(value) => {
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{BOOKING_KIND_TABS.map((t) => (
<Tabs.Tab key={t.value} value={t.value}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
searchable
radius="lg"
style={{ minWidth: 200 }}
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
/>
</Group>
</Stack>
</Box>
{isOperationsTab ? (
<Box px="md" pb="md">
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
</Box>
) : showEmpty ? (
{showEmpty ? (
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}

View File

@@ -50,6 +50,7 @@ import {
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et";
@@ -446,6 +447,8 @@ export default function ContractClearanceListPage() {
]}
/>
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
{queueTabOptions.length > 1 ? (

View File

@@ -61,9 +61,11 @@ import {
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { fileViewUrl } from "@/constants/apiConfig";
import { downloadBookingFile } from "@/services/files.service";
import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize).
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
enabled: Boolean(id) && showClearanceTabQuery,
});
// Customer profile documents (national ID, TIN, import/business license) for
// the company this contract belongs to. Shown as a separate section in the
// Documents tab, alongside the contract's own attached files.
const companyId = contract?.companyId ?? "";
const profileDocumentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: companyId },
enabled: Boolean(companyId),
}),
);
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
? profileDocumentsQuery.data
: [];
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
const profileDocuments = profileDocumentsRaw.map(
(doc: CustomerDocument) =>
({
id: doc.id,
code: doc.code,
name: doc.name,
url: doc.url ?? "",
mimeType: doc.mimeType,
size: doc.size,
resourceId: companyId,
resource: "company",
}) satisfies NonNullable<Freight.IContract["files"]>[number],
);
const downloadContractPdf = async () => {
if (!contract?.id) return;
try {
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
const selfClear = !contract.customsClearingEnabled;
const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract");
// Signature files (code `signature_<role>`) are baked into the contract PDF —
// don't list them as standalone documents in the Documents tab.
const contractDocuments = files.filter(
(f) => !f.code.startsWith("signature_"),
);
const hasContractDocument = Boolean(
contractPdf || contract.contractGeneratedAt,
);
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
value="documents"
leftSection={<Files size={16} />}
rightSection={
files.length > 0 ? (
contractDocuments.length + profileDocuments.length > 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{files.length}
{contractDocuments.length + profileDocuments.length}
</Badge>
) : null
}
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
) : currentTab === "documents" ? (
<Stack gap="lg">
<ContractDocumentsCard
files={files}
files={contractDocuments}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
emptyText={
profileDocumentsQuery.isLoading
? "Loading customer documents…"
: "No profile documents on file for this customer."
}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
@@ -550,21 +596,58 @@ export default function ContractRequestDetailPage() {
No cargo scope lines.
</Text>
) : (
<Stack gap="xs">
{(contract.cargoScope ?? []).map((s) => (
<Group key={s.id} gap={8} wrap="nowrap">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="sm">
{s.containerSize ??
s.cargoFreeText ??
s.cargoTypeId ??
"Cargo"}
</Text>
</Group>
))}
<Stack gap="sm">
{(contract.cargoScope ?? []).map((s) => {
const isContainer = Boolean(s.containerSize);
// Bulk lines carry their commodity detail (name + unit);
// container lines carry the size (20ft / 40ft).
const title = isContainer
? `${s.containerSize} container`
: (s.cargoType?.cargoTypeName ??
s.cargoFreeText ??
s.cargoType?.code ??
"Bulk cargo");
// quantityCap unit: containers for a size line, else the
// cargo type's unit of measure (tons / items / …), default tons.
const capUnit = isContainer
? "containers"
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
return (
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<Text size="sm" fw={500}>
{title}
</Text>
<Group gap={6} mt={2}>
<Badge
variant="light"
color={isContainer ? "blue" : "grape"}
radius="sm"
size="xs"
tt="uppercase"
>
{isContainer ? "Container" : "Bulk"}
</Badge>
{s.cargoType?.code ? (
<Text size="xs" c="dimmed">
Code: {s.cargoType.code}
</Text>
) : null}
<Text size="xs" c="dimmed">
{s.quantityCap != null
? `Cap: ${s.quantityCap} ${capUnit}`
: "Cap: uncapped"}
</Text>
</Group>
</div>
</Group>
);
})}
</Stack>
)}
</SectionCard>

View File

@@ -185,7 +185,7 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
<Grid gutter="lg">
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />

View File

@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(

View File

@@ -0,0 +1,286 @@
import { useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Timeline,
Title,
} from "@mantine/core";
import {
ArrowLeft,
History,
Route,
ShieldCheck,
Truck,
User,
} from "lucide-react";
import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
const fmtDate = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
};
const fmtDateTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const meta = (e: FleetHistoryEvent, k: string) => {
const v = e.metadata?.[k];
return typeof v === "string" && v ? v : null;
};
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase">{label}</Text>
<Text size="sm" fw={500} ta="right">{value}</Text>
</Group>
);
const Loading = () => (
<Center py="xl"><Loader size="sm" /></Center>
);
const DriverDetailPage = () => {
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: driver, isLoading } = useQuery({
queryKey: ["driver", id],
queryFn: () => driversService.getById(id).then((r) => r.data),
enabled: Boolean(id),
});
const name = driver ? `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() : "";
const licenseExpired =
driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date();
return (
<Container size="xl" py="xl" px="lg">
<Group gap="sm" mb="md" wrap="nowrap">
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/drivers")} aria-label="Back">
<ArrowLeft size={18} />
</ActionIcon>
<User size={22} />
<Stack gap={0}>
<Title order={4}>{name || "Driver"}</Title>
{driver && (
<Group gap="xs">
<Badge size="sm" variant="light">{driver.status}</Badge>
{driver.faydaVerified && (
<Badge size="sm" variant="light" color="green" leftSection={<ShieldCheck size={12} />}>
Fayda verified
</Badge>
)}
{licenseExpired && (
<Badge size="sm" variant="light" color="red">License expired</Badge>
)}
<Text size="xs" c="dimmed">{driver.licenseNumber}</Text>
</Group>
)}
</Stack>
</Group>
{isLoading ? (
<Loading />
) : !driver ? (
<Text c="dimmed">Driver not found.</Text>
) : (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<User size={14} />}>Overview</Tabs.Tab>
<Tabs.Tab value="vehicles" leftSection={<Truck size={14} />}>Vehicles</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="trips" leftSection={<Route size={14} />}>Trips</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Card withBorder radius="md" padding="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Name" value={name || "—"} />
<InfoRow label="Phone" value={driver.phoneNumber ?? "—"} />
<InfoRow label="Email" value={driver.email ?? "—"} />
<InfoRow label="License no." value={driver.licenseNumber ?? "—"} />
<InfoRow
label="License expiry"
value={
<Text size="sm" fw={500} c={licenseExpired ? "red" : undefined}>
{fmtDate(driver.licenseExpiryDate)}
</Text>
}
/>
<InfoRow label="Date of birth" value={fmtDate(driver.dateOfBirth)} />
<InfoRow label="Status" value={driver.status ?? "—"} />
<InfoRow label="Fayda verified" value={driver.faydaVerified ? "Yes" : "No"} />
<InfoRow
label="Authorized vehicles"
value={driver.vehicleTypesAuthorized?.join(", ") || "—"}
/>
<InfoRow label="Total trips" value={driver.totalTrips ?? "—"} />
<InfoRow label="Rating" value={driver.rating ?? "—"} />
<InfoRow label="Address" value={driver.address ?? "—"} />
<InfoRow label="Emergency contact" value={driver.emergencyContact ?? "—"} />
</SimpleGrid>
</Card>
</Tabs.Panel>
<Tabs.Panel value="vehicles" pt="lg">
<VehiclesTab driverId={id} />
</Tabs.Panel>
<Tabs.Panel value="history" pt="lg">
<HistoryTab driverId={id} />
</Tabs.Panel>
<Tabs.Panel value="trips" pt="lg">
<TripsTab driverId={id} />
</Tabs.Panel>
</Tabs>
)}
</Container>
);
};
const useDriverHistory = (driverId: string) =>
useQuery({
queryKey: ["driver-history", driverId],
queryFn: () => fleetHistoryService.driver(driverId),
});
/** id → "code · plate" map so events without a stored plate still show a name. */
const useVehicleMap = () => {
const { data } = useQuery({
queryKey: ["vehicles-all"],
queryFn: () => vehiclesService.getAll({}).then((r) => r.data),
});
return useMemo(() => {
const m = new Map<string, string>();
for (const v of data ?? []) {
m.set(v.id, [v.code, v.plateNumber].filter(Boolean).join(" · ") || v.id);
}
return m;
}, [data]);
};
const VehiclesTab = ({ driverId }: { driverId: string }) => {
const { data = [], isLoading } = useDriverHistory(driverId);
const vmap = useVehicleMap();
const rows = data
.filter((e) => e.eventType === "DRIVER_ASSIGNED")
.map((e) => ({
id: e.id,
plate: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "Vehicle",
at: e.createdAt,
}));
if (isLoading) return <Loading />;
return (
<Stack gap={4}>
<Text size="sm" fw={600}>Vehicles driven ({rows.length})</Text>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">No vehicle assignments recorded.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr><Table.Th>Vehicle</Table.Th><Table.Th>Assigned</Table.Th></Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{fmtDateTime(r.at)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
);
};
const HistoryTab = ({ driverId }: { driverId: string }) => {
const { data = [], isLoading } = useDriverHistory(driverId);
if (isLoading) return <Loading />;
if (!data.length) return <Text c="dimmed">No activity recorded yet.</Text>;
return (
<Timeline active={data.length} bulletSize={18} lineWidth={2}>
{data.map((e) => (
<Timeline.Item key={e.id} title={<Text size="sm" fw={500}>{e.eventType.replaceAll("_", " ")}</Text>}>
{(meta(e, "vehiclePlate") || meta(e, "bookingRef") || e.label) && (
<Text size="xs" c="dimmed">
{[meta(e, "vehiclePlate"), meta(e, "bookingRef") && `Booking ${meta(e, "bookingRef")}`, e.label]
.filter(Boolean)
.join(" · ")}
</Text>
)}
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
</Timeline.Item>
))}
</Timeline>
);
};
const TripsTab = ({ driverId }: { driverId: string }) => {
const { data = [], isLoading } = useDriverHistory(driverId);
const vmap = useVehicleMap();
const trips = useMemo(
() =>
data
.filter((e) => e.eventType === "MILE_VEHICLE_ASSIGNED")
.map((e) => ({
id: e.id,
mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile",
booking: meta(e, "bookingRef") ?? "—",
vehicle: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "—",
status: e.label ?? "—",
at: e.createdAt,
})),
[data, vmap],
);
if (isLoading) return <Loading />;
return (
<Stack gap={4}>
<Text size="sm" fw={600}>Trips assigned ({trips.length})</Text>
{trips.length === 0 ? (
<Text size="sm" c="dimmed">No trips recorded.</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Mile</Table.Th><Table.Th>Booking</Table.Th>
<Table.Th>Vehicle</Table.Th><Table.Th>Status</Table.Th><Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trips.map((t) => (
<Table.Tr key={t.id}>
<Table.Td><Badge size="sm" variant="light">{t.mile}</Badge></Table.Td>
<Table.Td>{t.booking}</Table.Td>
<Table.Td>{t.vehicle}</Table.Td>
<Table.Td>{t.status}</Table.Td>
<Table.Td>{fmtDateTime(t.at)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
);
};
export default DriverDetailPage;

View File

@@ -1,26 +1,10 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core';
import { Card, Stack, Group, Grid, Select, Text, RingProgress, Container, Title } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { api } from '@/auth/http';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalFuel: number;
totalCost: number;
averageCostPerLiter: number;
}
interface MaintenanceStats {
vehicleId: string;
totalCost: number;
numberOfMaintenanceItems: number;
averageCostPerMaintenance: number;
costByType: Record<string, number>;
}
interface CombinedReport {
vehicleId: string;
@@ -31,6 +15,9 @@ interface CombinedReport {
maintenancePercentage: number;
}
const etb = (n: number) =>
'ETB ' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function FinancialReportsPage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [months, setMonths] = useState('12');
@@ -45,13 +32,21 @@ export function FinancialReportsPage() {
const { data: fuelStats } = useQuery({
queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null),
queryFn: async () => {
if (!selectedVehicle) return Promise.resolve(null);
const res = await api.get(`/fuel/stats/${selectedVehicle}?months=${months}`);
return res.data;
},
enabled: !!selectedVehicle,
});
const { data: maintenanceStats } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null),
queryFn: async () => {
if (!selectedVehicle) return Promise.resolve(null);
const res = await api.get(`/maintenance/stats/${selectedVehicle}`);
return res.data;
},
enabled: !!selectedVehicle,
});
@@ -60,11 +55,11 @@ export function FinancialReportsPage() {
[vehicles]
);
const report = useMemo(() => {
if (!fuelStats || !maintenanceStats) return null;
const report = useMemo<CombinedReport | null>(() => {
if (!fuelStats && !maintenanceStats) return null;
const fuelCost = Number(fuelStats.totalCost) || 0;
const maintenanceCost = Number(maintenanceStats.totalCost) || 0;
const fuelCost = Number(fuelStats?.totalCost ?? 0) || 0;
const maintenanceCost = Number(maintenanceStats?.totalCost ?? 0) || 0;
const total = fuelCost + maintenanceCost;
return {
@@ -93,6 +88,9 @@ export function FinancialReportsPage() {
return (
<Container size="xl" py="xl" px="lg">
<Stack gap="md">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Financial Reports' }]} />
<Title order={1}>Financial Reports</Title>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Financial Analysis</Text>
@@ -126,13 +124,13 @@ export function FinancialReportsPage() {
<>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
<StatCard label="Total Operating Cost" value={etb(report.totalOperatingCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
<StatCard label="Fuel Cost" value={etb(report.fuelCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
<StatCard label="Maintenance Cost" value={etb(report.maintenanceCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder>
@@ -141,7 +139,7 @@ export function FinancialReportsPage() {
Monthly Avg
</Text>
<Text fw={700} size="lg">
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
{etb(report.totalOperatingCost / parseInt(months))}
</Text>
</Card.Section>
</Card>
@@ -166,7 +164,7 @@ export function FinancialReportsPage() {
<RingProgress
sections={[{ value: report.fuelPercentage, color: 'edr-accent' }]}
label={
<Text size="xs" align="center">
<Text size="xs" ta="center">
{report.fuelPercentage}%
</Text>
}
@@ -184,7 +182,7 @@ export function FinancialReportsPage() {
<RingProgress
sections={[{ value: report.maintenancePercentage, color: 'edr-red' }]}
label={
<Text size="xs" align="center">
<Text size="xs" ta="center">
{report.maintenancePercentage}%
</Text>
}
@@ -228,7 +226,7 @@ export function FinancialReportsPage() {
<Text size="sm" c="dimmed">
Avg Maintenance Cost
</Text>
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
<Text fw={500}>{etb(maintenanceStats?.averageCostPerMaintenance ?? 0)}</Text>
</div>
</Stack>
</Card.Section>

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core';
import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -39,7 +40,20 @@ interface FleetMetrics {
assignedDrivers: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => (
/** ETB money, whole-birr for dashboard headlines. */
const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
/** Safe percentage — 0 when the denominator is 0 (empty fleet). */
const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0);
interface StatCardProps {
icon: LucideIcon;
label: string;
value: string | number;
color?: string;
change?: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
@@ -111,8 +125,8 @@ export function FleetDashboard() {
const totalDrivers = (drivers as Driver[]).length;
const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length;
const fuelTotal = fuelStats?.totalCost || 0;
const maintenanceTotal = maintenanceStats?.totalCost || 0;
const fuelTotal = Number(fuelStats?.totalCost) || 0;
const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0;
return {
totalVehicles,
@@ -152,10 +166,10 @@ export function FleetDashboard() {
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={`$${metrics.totalFuelSpend.toFixed(0)}`} color="edr-accent" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={`$${metrics.totalMaintenanceSpend.toFixed(0)}`} color="edr-red" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
</Grid.Col>
</Grid>
@@ -173,7 +187,7 @@ export function FleetDashboard() {
<Text size="sm">Active Vehicles</Text>
<Text fw={700}>{metrics.activeVehicles} / {metrics.totalVehicles}</Text>
</Group>
<Progress value={(metrics.activeVehicles / metrics.totalVehicles) * 100} color="edr-green" />
<Progress value={pct(metrics.activeVehicles, metrics.totalVehicles)} color="edr-green" />
</div>
<div>
@@ -189,7 +203,7 @@ export function FleetDashboard() {
<Text size="sm">Idle / Under Maintenance</Text>
<Text fw={700}>{metrics.totalVehicles - metrics.activeVehicles}</Text>
</Group>
<Progress value={((metrics.totalVehicles - metrics.activeVehicles) / metrics.totalVehicles) * 100} color="edr-amber-soft" />
<Progress value={pct(metrics.totalVehicles - metrics.activeVehicles, metrics.totalVehicles)} color="edr-amber-soft" />
</div>
</Stack>
</Card.Section>
@@ -212,7 +226,7 @@ export function FleetDashboard() {
label={
<div style={{ textAlign: 'center' }}>
<Text fw={700} size="sm">
${operatingCost.toFixed(0)}
{etb(operatingCost)}
</Text>
<Text size="xs" c="dimmed">
Total Cost
@@ -325,13 +339,12 @@ export function FleetDashboard() {
</Table.Td>
<Table.Td>{d.licenseNumber || 'N/A'}</Table.Td>
<Table.Td>
<Stack gap={0} size="xs">
<Stack gap={0}>
{d.phone && (
<Text size="xs">
<Group gap={4} inline>
<MapPin size={12} /> {d.phone}
</Group>
</Text>
<Group gap={4}>
<MapPin size={12} />
<Text size="xs">{d.phone}</Text>
</Group>
)}
{d.email && <Text size="xs">{d.email}</Text>}
</Stack>

View File

@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onHistory={setHistoryTarget}
/>
</div>
),
@@ -354,7 +357,7 @@ const FleetResourcePage = () => {
const itemLabel = config.label.toLowerCase();
return (
<Container size="xxl" py="lg">
<Container size="xxl" py="lg" px="lg">
<Breadcrumbs items={[{ label: config.label }]} />
<Stack gap="lg" mt="sm">
@@ -510,6 +513,7 @@ const FleetResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
verifyWithFayda={Boolean(config.faydaVerification)}
/>
<Modal
@@ -580,6 +584,13 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
</Container>
);
};

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
@@ -17,13 +17,12 @@ import {
Badge,
Grid,
} from "@mantine/core";
import { Plus, Trash2 } from "lucide-react";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
import { freightBrand } from "@/theme/freight-brand";
interface FuelPurchase {
id: string;
@@ -67,7 +66,7 @@ export default function FuelPurchasePage() {
});
// Fetch fuel purchases
const { data: purchasesData = [] } = useQuery({
const { data: purchasesData = [], isLoading: isLoadingPurchases } = useQuery({
queryKey: ["fuel-purchases"],
queryFn: async () => {
const res = await api.get("/fuel/purchases");
@@ -104,8 +103,8 @@ export default function FuelPurchasePage() {
onError: (error: any) => {
toast({
title: "Error recording purchase",
message: error?.response?.data?.message || "Failed to record fuel purchase",
color: "red",
description: error?.response?.data?.message || "Failed to record fuel purchase",
variant: "destructive",
});
},
});
@@ -118,6 +117,17 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
);
const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.totalCost),
0
);
const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Record Purchase" }]} />
@@ -147,10 +157,7 @@ export default function FuelPurchasePage() {
Total Liters
</Text>
<Text fw={700} size="lg">
{purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0)
.toFixed(2)}{" "}
L
{totalLiters.toFixed(2)} L
</Text>
</Card>
</Grid.Col>
@@ -160,9 +167,7 @@ export default function FuelPurchasePage() {
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0)
.toLocaleString("en-US", { maximumFractionDigits: 2 })}
ETB {totalPurchaseCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
@@ -172,11 +177,7 @@ export default function FuelPurchasePage() {
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB{" "}
{(
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) /
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0
).toFixed(2)}
ETB {avgPricePerLiter.toFixed(2)}
</Text>
</Card>
</Grid.Col>
@@ -197,6 +198,23 @@ export default function FuelPurchasePage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoadingPurchases ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : purchasesData.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No fuel purchases recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{(purchasesData as FuelPurchase[])?.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>

View File

@@ -1,20 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core";
import { Card, Container, Grid, Group, Loader, Select, Stack, Text, Title } from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
import { useState } from "react";
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalLiters: number;
totalCost: number;
averagePricePerLiter: number;
dateRange: { startDate: string; endDate: string };
}
export default function FuelStatsPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
const [monthsBack, setMonthsBack] = useState<string>("12");
@@ -29,7 +20,7 @@ export default function FuelStatsPage() {
});
// Fetch fuel stats
const { data: statsData } = useQuery({
const { data: statsData, isFetching: isStatsFetching } = useQuery({
queryKey: ["fuel-stats", selectedVehicleId, monthsBack],
queryFn: async () => {
if (!selectedVehicleId) return null;
@@ -102,7 +93,7 @@ export default function FuelStatsPage() {
Total Purchases
</Text>
<Text fw={700} size="lg">
{statsData.totalPurchases}
{Number(statsData.totalPurchases) || 0}
</Text>
</Card>
</Grid.Col>
@@ -112,7 +103,7 @@ export default function FuelStatsPage() {
Total Fuel
</Text>
<Text fw={700} size="lg">
{statsData.totalLiters.toFixed(2)} L
{(Number(statsData.totalLiters) || 0).toFixed(2)} L
</Text>
</Card>
</Grid.Col>
@@ -122,7 +113,7 @@ export default function FuelStatsPage() {
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
@@ -132,7 +123,7 @@ export default function FuelStatsPage() {
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB {statsData.averagePricePerLiter.toFixed(2)}
ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)}
</Text>
</Card>
</Grid.Col>
@@ -172,15 +163,15 @@ export default function FuelStatsPage() {
<Text size="sm">
{selectedVehicle?.plateNumber} consumed{" "}
<Text fw={700} span>
{statsData.totalLiters.toFixed(2)} liters
{(Number(statsData.totalLiters) || 0).toFixed(2)} liters
</Text>{" "}
over the last {monthsBack} months, costing{" "}
<Text fw={700} span>
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
. Average fuel price was{" "}
<Text fw={700} span>
ETB {statsData.averagePricePerLiter.toFixed(2)} per liter
ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} per liter
</Text>
.
</Text>
@@ -188,6 +179,12 @@ export default function FuelStatsPage() {
</Stack>
</Card>
</>
) : selectedVehicleId && isStatsFetching ? (
<Card withBorder padding="lg">
<Group justify="center">
<Loader />
</Group>
</Card>
) : (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">

View File

@@ -1,12 +1,26 @@
import { useState, useMemo } from 'react';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import {
Card,
Button,
Modal,
Stack,
Group,
Select,
TextInput,
NumberInput,
Table,
Badge,
Text,
Title,
Container,
} from '@mantine/core';
import { Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { api } from '@/auth/http';
import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service';
interface MaintenanceSchedule {
id: string;
@@ -21,21 +35,23 @@ interface MaintenanceSchedule {
serviceProvider?: string;
}
const emptyForm = {
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
estimatedCost: 0,
serviceProvider: '',
notes: '',
};
export function MaintenancePage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [openScheduleModal, setOpenScheduleModal] = useState(false);
const [formData, setFormData] = useState({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
});
const [formData, setFormData] = useState(emptyForm);
const queryClient = useQueryClient();
const { data: vehicles } = useQuery({
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
@@ -45,36 +61,49 @@ export function MaintenancePage() {
const { data: upcoming, isLoading } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]),
queryFn: async () => {
if (!selectedVehicle) return [];
const res = await api.get(`/maintenance/upcoming/${selectedVehicle}`);
return res.data || [];
},
enabled: !!selectedVehicle,
});
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
const scheduleMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
return api.post('/maintenance/schedules', {
const res = await api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') });
toast({ title: 'Maintenance scheduled' });
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
});
setOpenScheduleModal(false);
setFormData({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
setFormData(emptyForm);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: v.plateNumber
? `${v.plateNumber} - ${v.manufacturer} ${v.model}`
: v.registrationNumber || v.id,
})) || [];
const statusColor = (status: string) => {
const colors: Record<string, string> = {
@@ -88,66 +117,85 @@ export function MaintenancePage() {
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Maintenance' }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Maintenance</Title>
<Button
onClick={() => setOpenScheduleModal(true)}
color="edr-green"
leftSection={<Plus size={16} />}
disabled={!selectedVehicle}
>
New Schedule
</Button>
</Group>
<Stack gap="md">
<Card>
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Schedule Maintenance</Text>
<Button onClick={() => setOpenScheduleModal(true)} color="edr-green" leftSection={<Plus size={16} />}>
New Schedule
</Button>
</Group>
</Card.Section>
<Card.Section p="md">
<Card withBorder padding="md">
<Select
label="Select Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
searchable
/>
</Card.Section>
</Card>
{selectedVehicle && (
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : (upcoming || []).length > 0 ? (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(upcoming as MaintenanceSchedule[]).map(m => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>${m.estimatedCost?.toFixed(2) || '—'}</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
)}
{!selectedVehicle ? (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">
Select a vehicle to view its maintenance schedule
</Text>
</Card>
) : (
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{upcomingList.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>
{m.estimatedCost != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
)}
</Stack>
<Modal
opened={openScheduleModal}
@@ -160,47 +208,52 @@ export function MaintenancePage() {
label="Type"
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={formData.maintenanceType}
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
/>
<TextInput
label="Description"
placeholder="What needs to be done?"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.currentTarget.value })}
onChange={(e) => setFormData({ ...formData, description: e.currentTarget.value })}
/>
<DateInput
<TextInput
label="Scheduled Date"
type="date"
value={formData.scheduledDate}
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
onChange={(e) => setFormData({ ...formData, scheduledDate: e.currentTarget.value })}
/>
<NumberInput
label="Estimated Cost"
min={0}
value={formData.estimatedCost}
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
onChange={(v) => setFormData({ ...formData, estimatedCost: Number(v) })}
/>
<TextInput
label="Service Provider"
placeholder="e.g., John's Auto Repair"
value={formData.serviceProvider}
onChange={e => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
onChange={(e) => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Additional notes"
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.currentTarget.value })}
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setOpenScheduleModal(false)}>
Cancel
</Button>
<Button onClick={() => scheduleMutation.mutate()} loading={scheduleMutation.isPending}>
<Button
onClick={() => scheduleMutation.mutate()}
loading={scheduleMutation.isPending}
disabled={!selectedVehicle}
>
Schedule
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</Container>
);
}

View File

@@ -24,7 +24,6 @@ import {
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";

View File

@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, ThemeIcon, SimpleGrid } from '@mantine/core';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
@@ -24,8 +24,8 @@ interface GPSLocation {
lastUpdate?: string;
}
// Mock GPS data for demo
const generateMockGPS = (index: number): GPSLocation => ({
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
@@ -36,7 +36,6 @@ const generateMockGPS = (index: number): GPSLocation => ({
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const mapZoom = 10;
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
@@ -48,9 +47,9 @@ export function TrackingPage() {
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v, idx) => ({
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(idx),
gps: generateMockGPS(),
}));
}, [vehicles]);
@@ -84,9 +83,14 @@ export function TrackingPage() {
<Stack gap="xl">
<Group justify="space-between">
<div>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
@@ -109,17 +113,18 @@ export function TrackingPage() {
</Card.Section>
<Card.Section p="md">
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
>
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
>
{/* Grid background */}
<svg
width={mapWidth}
@@ -199,7 +204,11 @@ export function TrackingPage() {
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
</Text>
</Card.Section>
</Card>
</Grid.Col>
@@ -314,7 +323,7 @@ export function TrackingPage() {
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table size="sm">
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr

View File

@@ -0,0 +1,453 @@
import { useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Timeline,
Title,
} from "@mantine/core";
import {
ArrowLeft,
Fuel,
History,
Route,
Truck,
User,
Wrench,
} from "lucide-react";
import { api } from "@/auth/http";
import { vehiclesService } from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service";
interface MaintenanceCost {
id: string;
incurredDate: string;
costAmount: number;
costType: string;
description?: string | null;
serviceProvider?: string | null;
invoiceNumber?: string | null;
}
interface FuelPurchase {
id: string;
purchaseDate: string;
liters: number;
costPerLiter: number;
totalCost: number;
fuelStation?: string | null;
odometerReading?: number | null;
}
interface MileRecord {
id: string;
status: string;
exactKm?: number | null;
estimatedKm?: number | null;
remainingPayment?: number | null;
advancedPayment?: number | null;
booking?: { reference?: string } | null;
bookingId: string;
}
const fmtDate = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
};
const fmtDateTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const money = (n?: number | null) =>
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase">{label}</Text>
<Text size="sm" fw={500} ta="right">{value}</Text>
</Group>
);
const Loading = () => (
<Center py="xl"><Loader size="sm" /></Center>
);
const VehicleDetailPage = () => {
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: vehicle, isLoading } = useQuery({
queryKey: ["vehicle", id],
queryFn: () => vehiclesService.getById(id).then((r) => r.data),
enabled: Boolean(id),
});
const plate = vehicle
? [vehicle.code, vehicle.plateNumber].filter(Boolean).join(" · ")
: "";
return (
<Container size="xl" py="xl" px="lg">
<Group gap="sm" mb="md" wrap="nowrap">
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/vehicles")} aria-label="Back">
<ArrowLeft size={18} />
</ActionIcon>
<Truck size={22} />
<Stack gap={0}>
<Title order={4}>{plate || "Vehicle"}</Title>
{vehicle && (
<Group gap="xs">
<Badge size="sm" variant="light">{vehicle.status}</Badge>
<Badge size="sm" variant="light" color={vehicle.availability === "FREE" ? "green" : "orange"}>
{vehicle.availability}
</Badge>
{vehicle.vehicleType && <Text size="xs" c="dimmed">{vehicle.vehicleType}</Text>}
</Group>
)}
</Stack>
</Group>
{isLoading ? (
<Loading />
) : !vehicle ? (
<Text c="dimmed">Vehicle not found.</Text>
) : (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<Truck size={14} />}>Overview</Tabs.Tab>
<Tabs.Tab value="driver" leftSection={<User size={14} />}>Driver</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Card withBorder radius="md" padding="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Plate" value={vehicle.plateNumber ?? "—"} />
<InfoRow label="Code" value={vehicle.code ?? "—"} />
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
<InfoRow label="Model" value={vehicle.model ?? "—"} />
<InfoRow label="Year" value={vehicle.year ?? "—"} />
<InfoRow label="Fuel type" value={vehicle.fuelType ?? "—"} />
<InfoRow label="Capacity" value={vehicle.capacity ?? "—"} />
<InfoRow label="Power plate" value={vehicle.powerPlateNo ?? "—"} />
<InfoRow label="Trailer plate" value={vehicle.trailerPlateNo ?? "—"} />
<InfoRow label="Status" value={vehicle.status ?? "—"} />
<InfoRow label="Availability" value={vehicle.availability ?? "—"} />
<InfoRow label="Assigned driver" value={vehicle.assignedDriverName ?? "—"} />
</SimpleGrid>
</Card>
</Tabs.Panel>
<Tabs.Panel value="driver" pt="lg">
<DriverTab vehicleId={id} driverId={vehicle.assignedDriverId} fallbackName={vehicle.assignedDriverName} />
</Tabs.Panel>
<Tabs.Panel value="history" pt="lg">
<HistoryTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="maintenance" pt="lg">
<MaintenanceTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="fuel" pt="lg">
<FuelTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="mile" pt="lg">
<MileTab vehicleId={id} />
</Tabs.Panel>
</Tabs>
)}
</Container>
);
};
const DriverTab = ({
vehicleId,
driverId,
fallbackName,
}: {
vehicleId: string;
driverId?: string | null;
fallbackName?: string | null;
}) => {
const { data: driver } = useQuery({
queryKey: ["driver", driverId],
queryFn: () => driversService.getById(driverId!).then((r) => r.data),
enabled: Boolean(driverId),
});
// All drivers that have driven this vehicle, from the assignment history.
const { data: history = [], isLoading } = useQuery({
queryKey: ["vehicle-history", vehicleId],
queryFn: () => fleetHistoryService.vehicle(vehicleId),
});
const drivers = history
.filter((e) => e.eventType === "DRIVER_ASSIGNED")
.map((e) => ({
id: e.id,
driverId: e.driverId,
name: (typeof e.metadata?.driverName === "string" && e.metadata.driverName) || e.label || "Driver",
at: e.createdAt,
}));
return (
<Stack gap="lg">
{driverId && driver ? (
<Card withBorder radius="md" padding="md">
<Text size="sm" fw={600} mb="sm">Current driver</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Name" value={`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() || "—"} />
<InfoRow label="Phone" value={driver.phoneNumber ?? "—"} />
<InfoRow label="Email" value={driver.email ?? "—"} />
<InfoRow label="License no." value={driver.licenseNumber ?? "—"} />
<InfoRow label="License expiry" value={fmtDate(driver.licenseExpiryDate)} />
<InfoRow label="Status" value={driver.status ?? "—"} />
</SimpleGrid>
</Card>
) : (
<Text c="dimmed">{fallbackName ? `Assigned: ${fallbackName}` : "No driver currently assigned."}</Text>
)}
<Stack gap={4}>
<Text size="sm" fw={600}>Driver history ({drivers.length})</Text>
{isLoading ? (
<Loading />
) : drivers.length === 0 ? (
<Text size="sm" c="dimmed">No driver assignments recorded.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Driver</Table.Th><Table.Th>Assigned</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{drivers.map((d) => (
<Table.Tr key={d.id}>
<Table.Td>{d.name}</Table.Td>
<Table.Td>{fmtDateTime(d.at)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Stack>
);
};
const HistoryTab = ({ vehicleId }: { vehicleId: string }) => {
const { data = [], isLoading } = useQuery({
queryKey: ["vehicle-history", vehicleId],
queryFn: () => fleetHistoryService.vehicle(vehicleId),
});
if (isLoading) return <Loading />;
if (!data.length) return <Text c="dimmed">No activity recorded yet.</Text>;
return (
<Timeline active={data.length} bulletSize={18} lineWidth={2}>
{data.map((e) => (
<Timeline.Item key={e.id} title={<Text size="sm" fw={500}>{e.eventType.replaceAll("_", " ")}</Text>}>
{(e.label || e.fromValue || e.toValue) && (
<Text size="xs" c="dimmed">
{[e.label, e.fromValue && e.toValue ? `${e.fromValue}${e.toValue}` : e.toValue]
.filter(Boolean)
.join(" · ")}
</Text>
)}
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
</Timeline.Item>
))}
</Timeline>
);
};
const MaintenanceTab = ({ vehicleId }: { vehicleId: string }) => {
const { data = [], isLoading } = useQuery({
queryKey: ["vehicle-maintenance", vehicleId],
queryFn: () => api.get<MaintenanceCost[]>(`/maintenance/history/${vehicleId}`).then((r) => r.data),
});
const total = useMemo(() => data.reduce((s, m) => s + (Number(m.costAmount) || 0), 0), [data]);
if (isLoading) return <Loading />;
return (
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">Last 12 months</Text>
<Text size="sm" fw={600}>Total: {money(total)}</Text>
</Group>
{data.length === 0 ? (
<Text c="dimmed">No maintenance records.</Text>
) : (
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th><Table.Th>Type</Table.Th><Table.Th>Amount</Table.Th>
<Table.Th>Description</Table.Th><Table.Th>Provider</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{fmtDate(m.incurredDate)}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{m.costType}</Badge></Table.Td>
<Table.Td>{money(m.costAmount)}</Table.Td>
<Table.Td>{m.description ?? "—"}</Table.Td>
<Table.Td>{m.serviceProvider ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
);
};
const FuelTab = ({ vehicleId }: { vehicleId: string }) => {
const { data = [], isLoading } = useQuery({
queryKey: ["vehicle-fuel", vehicleId],
queryFn: () => {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 12);
const qs = `startDate=${start.toISOString()}&endDate=${end.toISOString()}`;
return api.get<FuelPurchase[]>(`/fuel/purchases/${vehicleId}?${qs}`).then((r) => r.data);
},
});
const totals = useMemo(
() => ({
liters: data.reduce((s, f) => s + (Number(f.liters) || 0), 0),
cost: data.reduce((s, f) => s + (Number(f.totalCost) || 0), 0),
}),
[data],
);
if (isLoading) return <Loading />;
return (
<Stack gap="sm">
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
<Card withBorder radius="md" padding="sm">
<Text size="xs" c="dimmed">Total litres</Text>
<Text fw={700}>{totals.liters.toLocaleString(undefined, { maximumFractionDigits: 1 })} L</Text>
</Card>
<Card withBorder radius="md" padding="sm">
<Text size="xs" c="dimmed">Total fuel cost</Text>
<Text fw={700}>{money(totals.cost)}</Text>
</Card>
<Card withBorder radius="md" padding="sm">
<Text size="xs" c="dimmed">Purchases</Text>
<Text fw={700}>{data.length}</Text>
</Card>
</SimpleGrid>
{data.length === 0 ? (
<Text c="dimmed">No fuel purchases in the last 12 months.</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th><Table.Th>Litres</Table.Th><Table.Th>Cost/L</Table.Th>
<Table.Th>Total</Table.Th><Table.Th>Odometer</Table.Th><Table.Th>Station</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((f) => (
<Table.Tr key={f.id}>
<Table.Td>{fmtDate(f.purchaseDate)}</Table.Td>
<Table.Td>{f.liters} L</Table.Td>
<Table.Td>{money(f.costPerLiter)}</Table.Td>
<Table.Td>{money(f.totalCost)}</Table.Td>
<Table.Td>{f.odometerReading ?? "—"}</Table.Td>
<Table.Td>{f.fuelStation ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
);
};
const mileTotal = (rows: MileRecord[]) =>
rows.reduce((s, r) => s + (Number(r.remainingPayment) || 0), 0);
const MileTable = ({ title, rows }: { title: string; rows: MileRecord[] }) => (
<Stack gap={4}>
<Group justify="space-between">
<Text size="sm" fw={600}>{title} ({rows.length})</Text>
<Text size="sm" fw={600}>Total: {money(mileTotal(rows))}</Text>
</Group>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">None.</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Distance (km)</Table.Th><Table.Th>Cost</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.booking?.reference ?? r.bookingId}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{r.status}</Badge></Table.Td>
<Table.Td>{r.exactKm ?? r.estimatedKm ?? "—"}</Table.Td>
<Table.Td>{money(r.remainingPayment)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
);
const MileTab = ({ vehicleId }: { vehicleId: string }) => {
const first = useQuery({
queryKey: ["vehicle-first-mile", vehicleId],
queryFn: () =>
api.get<{ data: MileRecord[] }>(`/first-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []),
});
const last = useQuery({
queryKey: ["vehicle-last-mile", vehicleId],
queryFn: () =>
api.get<{ data: MileRecord[] }>(`/last-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []),
});
if (first.isLoading || last.isLoading) return <Loading />;
const firstRows = first.data ?? [];
const lastRows = last.data ?? [];
const grandTotal = mileTotal(firstRows) + mileTotal(lastRows);
return (
<Stack gap="lg">
<Card withBorder radius="md" padding="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">Total first + last mile revenue for this vehicle</Text>
<Text fw={700}>{money(grandTotal)}</Text>
</Group>
</Card>
<MileTable title="First-mile" rows={firstRows} />
<MileTable title="Last-mile" rows={lastRows} />
</Stack>
);
};
export default VehicleDetailPage;

View File

@@ -8,11 +8,18 @@ const DRIVER_STATUS_OPTIONS = [
{ label: "On leave", value: "ON_LEAVE" },
];
const DRIVER_GENDER_OPTIONS = [
{ label: "Male", value: "MALE" },
{ label: "Female", value: "FEMALE" },
{ label: "Other", value: "OTHER" },
];
export const driversConfig: FleetResourceConfig = {
slug: "drivers",
label: "Drivers",
subtitle: "Manage driver records and licenses",
basePath: "/dashboard/drivers",
detailPath: "/dashboard/drivers/:id",
addLabel: "Add Driver",
entityLabel: "Driver",
searchPlaceholder: "Search drivers…",
@@ -29,24 +36,28 @@ export const driversConfig: FleetResourceConfig = {
options: DRIVER_STATUS_OPTIONS,
},
],
faydaVerification: true,
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
{ id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 },
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
{ id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
],
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: "licenseNumber", label: "Driver's License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true },
{ name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true },
{ name: "email", label: "Email", type: "email", required: true, faydaLocked: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true },
{ name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" },
{ 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" },
@@ -60,6 +71,7 @@ export const driversConfig: FleetResourceConfig = {
email: "",
phoneNumber: "",
dateOfBirth: "",
gender: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],

View File

@@ -33,17 +33,27 @@ export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
size?: number;
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/**
* Field is owned by the Fayda identity — populated only by verification and
* never hand-edited. Rendered disabled in the form.
*/
faydaLocked?: boolean;
/**
* Direction a `date` field is constrained to. "future" = must be after today
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
}
export interface FleetListFilterDef {
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
@@ -73,6 +83,8 @@ export interface FleetResourceConfig {
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
/** Offer Fayda identity verification in the add/edit form (drivers). */
faydaVerification?: boolean;
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {

View File

@@ -34,6 +34,7 @@ export const vehiclesConfig: FleetResourceConfig = {
label: "Vehicles",
subtitle: "Manage vehicle master data for fleet operations",
basePath: "/dashboard/vehicles",
detailPath: "/dashboard/vehicles/:id",
addLabel: "Add Vehicle",
entityLabel: "Vehicle",
searchPlaceholder: "Search vehicles…",

View File

@@ -19,7 +19,6 @@ import {
import {
Boxes,
ChevronRight,
FileText,
Home,
Layers,
Package,
@@ -41,6 +40,7 @@ import {
import {
useRuleEngineList,
useRuleEngineMutations,
useWagonTypeOptions,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -54,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
wagonTypeId?: string | null;
isActive?: boolean;
displayOrder?: number;
}
@@ -79,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
],
},
{
// Wagon type that carries this (bulk) commodity — drives train-scheduling
// wagon resolution. Optional: leave "None" for grouping categories and
// container/legacy cargo; set it on scheduled bulk commodities.
// Options injected at render from useWagonTypeOptions.
name: "wagonTypeId",
label: "Wagon type",
type: "select",
optional: true,
placeholder: "Select wagon type (bulk cargo)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -105,6 +119,24 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
field.name === "wagonTypeId"
? {
...field,
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
...(wagonTypeOptions ?? []),
],
}
: field,
),
[wagonTypeOptions],
);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
@@ -350,7 +382,7 @@ const CargoTypesPage = () => {
? "Create a top-level cargo category."
: "Create a cargo type inside this category. It's attached here automatically."
}
fields={FORM_FIELDS}
fields={formFields}
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
isSubmitting={create.isPending || update.isPending}
onSubmit={handleSubmit}

View File

@@ -33,6 +33,7 @@ import {
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
const usesLiveRateField = Boolean(
config?.formFields.some((f) => f.name === "rateId"),
);
const usesWagonTypeField = Boolean(
config?.formFields.some((f) => f.name === "wagonTypeId"),
);
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const formFields = useMemo(() => {
if (!config) return [];
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
options: liveRateOptions ?? [],
};
}
if (field.name === "wagonTypeId") {
return {
...field,
type: "select" as const,
options: wagonTypeOptions ?? [],
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -341,6 +354,10 @@ const RuleEngineResourcePage = () => {
} 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()) };
} else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it).
payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null };
}
if (editing?.id) {
@@ -498,7 +515,8 @@ const RuleEngineResourcePage = () => {
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}

View File

@@ -47,6 +47,14 @@ export interface FormFieldDef {
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
/**
* Select options computed from other fields' current values. When set, the
* form resolves the option list at render time from the live form state
* instead of the static `options` list. Used for the rate unit selector,
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
*/
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
}
export interface RuleEngineOrderConfig {
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
];
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
(v) => ({
label: v.replace(/_/g, " "),
value: v,
}),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
case "OVERWEIGHT":
return ["PER_TON"];
case "REEFER":
case "HAZARDOUS":
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
return ["PER_CONTAINER", "FLAT"];
default:
return ["FLAT", "PER_TON", "PER_CONTAINER"];
}
}
switch (appliesTo) {
case "CONTAINER":
return ["PER_CONTAINER", "PER_WAGON"];
case "BULK":
return ["PER_TON", "PER_WAGON"];
case "INTERCITY":
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
case "FIRST_MILE":
case "LAST_MILE":
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
default:
return ["FLAT"];
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
return allowedRateUnits(appliesTo, trigger).map(unitOption);
};
const CURRENCIES = [
{ label: "USD", value: "USD" },
@@ -198,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
{
name: "wagonTypeId",
label: "Wagon type",
type: "select",
required: true,
description: "Wagon type used to carry this container during train scheduling.",
},
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],
@@ -324,8 +382,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
{
id: "maxCapacityTons",
header: "Max capacity (t)",
accessorKey: "maxCapacityTons",
format: "number",
},
],
formFields: [
{
@@ -343,8 +405,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS,
},
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
{
name: "maxCapacityTons",
label: "Max capacity (tons)",
type: "number",
optional: true,
description:
"Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).",
},
],
},
{
@@ -407,7 +475,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
],
formFields: [
{
@@ -457,9 +524,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
// is always per excess ton, so the unit field is hidden for it — the API
// forces PER_TON regardless.
{
name: "rateUnit",
label: "Rate unit",
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],
},
{

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
@@ -24,9 +23,7 @@ import {
Boxes,
CalendarDays,
CheckCircle2,
ChevronLeft,
ClipboardCheck,
ChevronRight,
Clock,
FileSignature,
Hourglass,
@@ -40,7 +37,7 @@ import {
XCircle,
} from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
}
/** "05 Jun 2026 · 06:00 09:00 EAT" → "06:00 09:00 EAT" (date lives in the day header). */
function timeLabelOf(label: string): string {
const idx = label.indexOf("·");
return idx >= 0 ? label.slice(idx + 1).trim() : label;
}
const EAT_TZ = "Africa/Addis_Ababa";
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
month: "short",
});
const timeFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
function windowDateKey(w: BatchWindowGroup): string {
if (w.date) return w.date;
if (w.start) return dateKeyFmt.format(new Date(w.start));
return "undated";
interface ScheduleWindow {
windowPhase: BatchBoardScheduleDetail["windowPhase"];
bookingWindowStatus: string;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo?: number;
}
/** Human day label for a window — prefers the API field, falls back to `start`. */
function windowDateLabel(w: BatchWindowGroup): string {
if (w.dateLabel) return w.dateLabel;
if (w.start) return dateLabelFmt.format(new Date(w.start));
return "Undated";
/**
* Deadline + label for the phase the schedule's booking window is currently in —
* the SAME phases the customer sees on the portal: pre-window (opens) → open
* (closes) → document review → payment. `expiredText` names the next step so a
* lapsed deadline reads as a handover, not a bare "Expired".
*/
function windowPhaseCountdown(
w: ScheduleWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? { label: "Booking opens in", deadline: w.windowOpensAt, expiredText: "Booking opening now…" }
: null;
case "OPEN":
return w.windowClosesAt
? { label: "Window closes in", deadline: w.windowClosesAt, expiredText: "Document review starting…" }
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? { label: "Document review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…" }
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" }
: null;
default:
return null;
}
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
/** One phase row: label + its clock time (or "—" when unset). */
function PhaseTimeRow({
label,
iso,
active,
}: {
label: string;
iso: string | null;
active: boolean;
}) {
return (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Text size="sm" fw={active ? 700 : 500} c={active ? "edr-green.7" : "dimmed"}>
{label}
</Text>
<Text size="sm" fw={active ? 700 : 500} c={active ? "dark" : "dimmed"}>
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
</Text>
</Group>
);
}
/**
* The schedule's REAL booking window — the exact same window the customer sees on
* the portal (frozen open/close from the schedule's own snapshot + the post-close
* document-review and payment phases), with a live countdown to the current phase.
* Replaces the old theoretical "3-hour windows across every day" projection.
*/
function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) {
const phase = w.windowPhase;
const cd = windowPhaseCountdown(w);
const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN";
const openDay = w.windowOpensAt
? dateLabelFmt.format(new Date(w.windowOpensAt))
: null;
return (
<Accordion.Item value={window.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
border: total
? "1px solid #FBD171"
: "1px solid var(--mantine-color-gray-2)",
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
}}
>
<Clock size={16} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
{timeLabelOf(window.label)}
</Text>
<Text size="xs" c="dimmed">
{total
? `${total} booking${total === 1 ? "" : "s"}`
: "Empty window"}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={window.counts} />
</Group>
<Paper
withBorder
radius="lg"
p="md"
mt="md"
style={{
borderColor: open
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-gray-2)",
background: open ? "var(--mantine-color-edr-green-0)" : undefined,
}}
>
<Group justify="space-between" wrap="nowrap" mb="sm">
<Group gap="sm" wrap="nowrap">
{phase ? (
<WindowPhasePill phase={phase} cycleNo={w.bookingCycleNo} />
) : null}
<WindowStatusPill status={w.bookingWindowStatus} />
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={window.bookings} />
</Accordion.Panel>
</Accordion.Item>
{openDay ? (
<Text size="xs" c="dimmed">
Booking day · {openDay}
</Text>
) : null}
</Group>
{cd ? (
<Box mb="sm">
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="md"
/>
</Box>
) : null}
<Stack gap={6}>
<PhaseTimeRow label="Window opens" iso={w.windowOpensAt} active={phase === "PRE_WINDOW"} />
<PhaseTimeRow label="Window closes" iso={w.windowClosesAt} active={phase === "OPEN"} />
<PhaseTimeRow label="Document review ends" iso={w.docReviewEndsAt} active={phase === "DOC_REVIEW"} />
<PhaseTimeRow label="Payment window ends" iso={w.paymentPhaseEndsAt} active={phase === "PAYMENT"} />
</Stack>
</Paper>
);
}
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
@@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() {
return [...byId.values()];
}, [data]);
// All bookings that fall inside the schedule's booking window (every window
// cycle, flattened) — the window is one booking day, so these belong to the
// single window panel above.
const windowBookings = useMemo(
() => (data?.windows ?? []).flatMap((w) => w.bookings),
[data?.windows],
);
const windowCounts = useMemo(() => {
const counts = {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
};
for (const b of windowBookings) {
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
}, [windowBookings]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
@@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() {
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
if (!data) return [];
const byDate = new Map<
string,
{
date: string;
dateLabel: string;
windows: BatchWindowGroup[];
totalBookings: number;
counts: BatchWindowGroup["counts"];
hasIssues: boolean;
}
>();
for (const w of data.windows) {
const dateKey = windowDateKey(w);
let group = byDate.get(dateKey);
if (!group) {
group = {
date: dateKey,
dateLabel: windowDateLabel(w),
windows: [],
totalBookings: 0,
counts: {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
},
hasIssues: false,
};
byDate.set(dateKey, group);
}
group.windows.push(w);
group.totalBookings += w.bookings.length;
group.counts.allocated += w.counts.allocated;
group.counts.selectedForBatch += w.counts.selectedForBatch;
group.counts.ready += w.counts.ready;
group.counts.waiting += w.counts.waiting;
group.counts.expired += w.counts.expired;
group.counts.pendingContract += w.counts.pendingContract;
group.hasIssues =
group.hasIssues ||
w.bookings.some(
(b) =>
b.allocationStatus === "FAILED" ||
b.allocationStatus === "DEFERRED",
);
}
return [...byDate.values()];
}, [data]);
// Windows with bookings open by default (inside an expanded day).
const openWindowKeys = useMemo(
() =>
data
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
: [],
[data],
);
const todayEat = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Africa/Addis_Ababa",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[],
);
// Date-stepper: which day is currently shown. Default to today, else the first
// day with bookings, else the first day. Keep the selection if still valid.
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,
);
useEffect(() => {
if (!dayGroups.length) return;
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
const preferred =
dayGroups.find((d) => d.date === todayEat) ??
dayGroups.find((d) => d.totalBookings > 0) ??
dayGroups[0];
setSelectedDate(preferred.date);
}, [dayGroups, selectedDate, todayEat]);
const selectedIndex = Math.max(
0,
dayGroups.findIndex((d) => d.date === selectedDate),
);
const selectedDay = dayGroups[selectedIndex];
const handleCompleteDocReview = () => {
completeDocReview
@@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() {
<Clock size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Batch windows (EAT)</Title>
<Title order={4}>Booking window (EAT)</Title>
<Text size="sm" c="dimmed">
3-hour windows for every day from when the booking window
opened through the departure date. Bookings appear under the
date their contract was signed open a day to see its
windows.
The schedule's real booking window the same window and
phase timings the customer sees on the portal. Bookings in
the window are listed below.
</Text>
</Box>
</Group>
{dayGroups.length && selectedDay ? (
<>
{/* Date stepper — page back/forward through each day in the range */}
<Group
justify="center"
align="center"
wrap="nowrap"
gap="md"
mt="md"
>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Previous day"
disabled={selectedIndex <= 0}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex - 1]?.date ?? null,
)
}
>
<ChevronLeft size={20} />
</ActionIcon>
<ScheduleWindowPanel window={data} />
<Paper
withBorder
radius="xl"
px="xl"
py="xs"
style={{
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings
? "#FEF1D5"
: "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{
color: selectedDay.totalBookings
? "#8A5304"
: "#0f172a",
}}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
</Paper>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex + 1]?.date ?? null,
)
}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
{windowBookings.length ? (
<Box mt="lg">
<Group justify="space-between" align="center" mb="sm">
<Text fw={700} size="sm">
Bookings in this window
</Text>
<Group gap={6} wrap="nowrap">
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
</Group>
<WindowCountChips counts={windowCounts} />
</Group>
<Accordion
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
<BookingTable bookings={windowBookings} />
</Box>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule.
No bookings in this window yet.
</Text>
)}

View File

@@ -8,6 +8,7 @@ import {
Paper,
RingProgress,
Stack,
Tabs,
Text,
Textarea,
TextInput,
@@ -25,10 +26,12 @@ import {
LayoutGrid,
Navigation,
Package,
PackageCheck,
Route as RouteIcon,
Send,
Train,
Weight,
Workflow as WorkflowIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -42,9 +45,11 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
@@ -141,6 +146,13 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
const eligibleFilters = useMemo(
() =>
schedule
@@ -951,6 +963,23 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
{schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Import loading confirmation</Text>
<Text size="sm" c="dimmed">
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
is tracking only it does not block dispatch.
</Text>
<ImportLoadingConfirmationPanel
scheduleId={scheduleId as string}
items={importLoadingQuery.data?.items ?? []}
isLoading={importLoadingQuery.isLoading}
/>
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
@@ -1028,6 +1057,18 @@ export default function TrainScheduleV2DetailPage() {
</Paper>
) : null}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
Workflow
</Tabs.Tab>
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
Workspace
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workflow">
<Stack gap="lg">
<Paper radius="xl" p="lg">
<Stack gap="lg">
{/* Workflow header with ring progress */}
@@ -1085,7 +1126,20 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
<ScheduleBatchPanel schedule={schedule} />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="workspace">
<ScheduleWorkspacePanel
schedule={schedule}
onChanged={() => {
autoPreviewedRef.current = false;
void detailQuery.refetch();
}}
/>
</Tabs.Panel>
</Tabs>
{scheduleId ? (
<RescheduleTrainDialog

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
@@ -10,38 +11,75 @@ export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
const [form, setForm] = useState<
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
>({});
useEffect(() => {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
// `numeric` columns come back from the API as strings (e.g. "250.00").
// Coerce every field to a real number so Mantine's controlled
// NumberInput edits cleanly (a string value fights the caret) and the
// default can be cleared and replaced.
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
setForm(numeric);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
}, [toast]);
// Run once on mount only. `toast` from useToast is a fresh function every
// render — listing it here re-fired the effect on every render, refetching
// the rules and overwriting whatever the user was typing (values snapped
// back to the saved defaults).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
// Every field must hold a real number — an empty box (cleared but not
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
const raw = form[key];
const num = raw === "" || raw == null ? NaN : Number(raw);
if (!Number.isFinite(num)) {
toast({
title: "All fields are required — fill every value before saving.",
variant: "destructive",
});
return;
}
payload[key] = num;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
importWindowLeadDays: Number(form.importWindowLeadDays),
exportBookingLeadHours: Number(form.exportBookingLeadHours),
windowOpenHour: Number(form.windowOpenHour),
windowDurationHours: Number(form.windowDurationHours),
docReviewMinutes: Number(form.docReviewMinutes),
paymentWindowMinutes: Number(form.paymentWindowMinutes),
reopenDelayMinutes: Number(form.reopenDelayMinutes),
});
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
@@ -65,8 +103,10 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -75,8 +115,10 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -84,8 +126,10 @@ export default function TrainSchedulingGlobalRulesPage() {
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowDecimal
min={1}
disabled={loading}
/>
@@ -96,9 +140,11 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: Number(value),
max20ftContainerWeightTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0.001}
disabled={loading}
/>
@@ -109,9 +155,11 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: Number(value),
max20ftPairWeightDiffTons: value,
}))
}
clampBehavior="none"
allowDecimal
min={0}
disabled={loading}
/>
@@ -124,22 +172,24 @@ export default function TrainSchedulingGlobalRulesPage() {
title="Booking windows"
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
/>
<NumberInput
label="Import window lead (days)"
description="The single booking day opens this many days before departure"
<DurationField
label="Import window lead"
description="The single booking day opens this long before departure"
value={form.importWindowLeadDays ?? ""}
nativeUnit="days"
onChange={(value) =>
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Export booking lead (hours)"
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
<DurationField
label="Export booking lead"
description="Export bookings are accepted first-come-first-serve starting this long before departure"
value={form.exportBookingLeadHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
disabled={loading}
@@ -149,49 +199,54 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<NumberInput
label="Window duration (hours)"
<DurationField
label="Window duration"
description="How long the import booking window stays open"
value={form.windowDurationHours ?? ""}
nativeUnit="hours"
onChange={(value) =>
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
}
min={0.25}
max={12}
step={0.25}
disabled={loading}
/>
<NumberInput
label="Document review (minutes)"
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Payment window (minutes)"
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
setForm((current) => ({ ...current, windowDurationHours: value }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Reopen delay (minutes)"
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
<DurationField
label="Document review"
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
disabled={loading}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}

View File

@@ -14,7 +14,17 @@ import {
Text,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Eye,
FileText,
History,
PackageOpen,
ShieldCheck,
Truck,
} from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
@@ -36,6 +46,7 @@ import {
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
AutoUnloadExportDjiboutiResult,
ExportTrain,
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument();
const qc = useQueryClient();
const secureGatePass = useMutation({
mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId),
onSuccess: () =>
qc.invalidateQueries({
queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'],
}),
});
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
@@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() {
.map((doc) => [doc.scheduleId as string, doc]),
);
const secureGate = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
await secureGatePass.mutateAsync(train.scheduleId);
toast({
title: 'Gate pass secured',
description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Could not secure gate pass',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
const unloadTrain = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
>
Open
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<ShieldCheck size={14} />}
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
onClick={() => secureGate(train)}
>
Secure Gate Pass
</Button>
<Button
size="compact-xs"
color="green"
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items

View File

@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
);
const warehousesQuery = useWarehouses();
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
return (
<PageContainer>
<PageHeader
title="Warehouse Inventory"
title={
direction === 'IMPORT'
? 'Import Terminal Inventory'
: direction === 'EXPORT'
? 'Export Terminal Inventory'
: 'Warehouse Inventory'
}
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Group gap="xs">

View File

@@ -33,7 +33,7 @@ import {
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
import { extractErrorMessage } from '@/components/warehouses/options';
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
@@ -253,7 +253,7 @@ function AllocationRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>
@@ -569,7 +569,7 @@ function FeeRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>

View File

@@ -44,13 +44,17 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
CompositionRemovalEntry,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
@@ -220,6 +224,13 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
),
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
"train-scheduling",
"all-booking-windows",
() => trainSchedulingService.getAllBookingWindows(),
() => ["train-scheduling", "all-booking-windows"],
),
batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail
@@ -281,6 +292,18 @@ export const api = {
],
),
contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>(
"train-scheduling",
"contract-booking-windows",
({ contractId }) =>
trainSchedulingService.getContractBookingWindows(contractId),
({ contractId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"contract-booking-windows",
contractId,
],
),
availableDays: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
string[]
@@ -357,6 +380,25 @@ export const api = {
QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
),
importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>(
"train-scheduling",
"import-loading-bookings",
({ id }) => trainSchedulingService.getImportLoadingBookings(id),
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id),
),
updateImportLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
ImportLoadingBookingsResponse
>(
"train-scheduling",
"update-import-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)],
),
// ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<
{ scheduleId: string },

View File

@@ -17,6 +17,8 @@ export interface BookingListFilter {
// customerId?: string;
companyId?: string;
freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
page?: number;
@@ -125,6 +127,7 @@ export const bookingsService = {
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
@@ -148,6 +151,7 @@ export const bookingsService = {
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
@@ -171,11 +175,6 @@ export const bookingsService = {
},
// ── Document clearance (GL workflow) ──
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
const response = await client.get(`/bookings/${id}/clearance`);
return unwrap(response.data) as Freight.ClearanceView;
},
reviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },

View File

@@ -27,6 +27,41 @@ export interface PaginatedContracts {
total: number;
}
/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */
export interface ShipmentPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
unit: string;
quantity: number;
currency: string;
}
/**
* Pre-create validation + authoritative price preview for a booking under a
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
* the same pricing pass the booking persists at create (rail freight,
* first/last mile, overweight and every other surcharge). `pairingErrors` and
* `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings.
*/
export interface ShipmentValidation {
overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
@@ -471,6 +506,18 @@ export const contractsService = {
payload: Freight.CreateBookingUnderContractDto,
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +
* first/last mile + every surcharge), plus overweight warnings and 20ft
* pairing hard-blocks. Shown in the GL price-confirm modal.
*/
validateShipment: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
) =>
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const response = await client.get(C.CAPACITY(id));

View File

@@ -26,6 +26,8 @@ export interface Driver {
address?: string | null;
emergencyContact?: string | null;
notes?: string | null;
faydaVerified?: boolean;
faydaSub?: string | null;
totalTrips: number;
rating: number;
createdAt: string;

View File

@@ -16,12 +16,24 @@ export interface FirstMileBooking {
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
totalAmount: number;
paymentCurrency?: string | null;
scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; label?: string } | null;
originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
quantity: number;
containerNumber?: string | null;
containerSize?: string | null;
containerType?: { id: string; name?: string; label?: string; code?: string } | null;
/** Physical containers under this line — their real numbers (line-level
* containerNumber is often a TBD placeholder). */
units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>;
}>;
}
export interface FirstMileVehicle {
@@ -29,9 +41,12 @@ export interface FirstMileVehicle {
plateNumber: string;
manufacturer: string;
model: string;
vehicleType?: string | null;
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
assignedDriverId?: string | null;
assignedDriverName?: string | null;
}
export interface FirstMileRecord {
@@ -45,6 +60,16 @@ export interface FirstMileRecord {
vehicleId?: string | null;
booking?: FirstMileBooking | null;
vehicle?: FirstMileVehicle | null;
/** Full set of vehicles serving this pickup (multi-truck). */
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
vehicle?: FirstMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
createdAt: string;
updatedAt: string;
}
@@ -66,4 +91,15 @@ export const firstMileService = {
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
remove: (id: string) =>
api.delete<void>(FM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
remainingPayment?: number,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }),
generateInvoice: (id: string) =>
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
};

View File

@@ -0,0 +1,38 @@
import { api as apiClient } from "../auth/http";
export type FleetEventType =
| "DRIVER_REGISTERED"
| "VEHICLE_REGISTERED"
| "DRIVER_ASSIGNED"
| "DRIVER_UNASSIGNED"
| "VEHICLE_STATUS_CHANGED"
| "VEHICLE_AVAILABILITY_CHANGED"
| "MILE_VEHICLE_ASSIGNED"
| "MILE_VEHICLE_RELEASED"
| "MILE_STATUS_CHANGED";
export interface FleetHistoryEvent {
id: string;
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
/** Timeline of fleet events for a driver or a vehicle (newest first). */
export const fleetHistoryService = {
driver: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/drivers/${id}/history`)
.then((r) => r.data),
vehicle: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/vehicles/${id}/history`)
.then((r) => r.data),
};

View File

@@ -16,12 +16,24 @@ export interface LastMileBooking {
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
totalAmount: number;
paymentCurrency?: string | null;
scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; name?: string; label?: string } | null;
originYard?: { id: string; name?: string; label?: string } | null;
destinationYard?: { id: string; name?: string; label?: string } | null;
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
quantity: number;
containerNumber?: string | null;
containerSize?: string | null;
containerType?: { id: string; name?: string; label?: string; code?: string } | null;
/** Physical containers under this line — their real numbers (line-level
* containerNumber is often a TBD placeholder). */
units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>;
}>;
}
export interface LastMileVehicle {
@@ -48,6 +60,16 @@ export interface LastMileRecord {
vehicleId?: string | null;
booking?: LastMileBooking | null;
vehicle?: LastMileVehicle | null;
/** Full set of vehicles serving this delivery (multi-truck). */
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
vehicle?: LastMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
createdAt: string;
updatedAt: string;
}
@@ -69,4 +91,15 @@ export const lastMileService = {
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
remove: (id: string) =>
api.delete<void>(LM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
remainingPayment?: number,
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
generateInvoice: (id: string) =>
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
};

View File

@@ -15,8 +15,6 @@ export interface Rate {
proposedByStaffId: string;
approvedByCeoId: string | null;
approvedAt: string | null;
effectiveFrom: string;
effectiveTo: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;

View File

@@ -6,6 +6,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
CompositionRemovalEntry,
UnassignedBookingsResponse,
@@ -15,9 +16,12 @@ import type {
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
@@ -106,6 +110,19 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Booking windows for every route/schedule of a contract. A window with
* `isOpenNow === true` means GL may create a booking right now for that route.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<BookingWindow[]> => {
const response = await client.get<BookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,
@@ -298,6 +315,26 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportLoadingBookings: async (
scheduleId: string,
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.get<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId),
);
return unwrap(response.data);
},
updateImportLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.patch<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {
@@ -507,6 +544,13 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getAllBookingWindows: async (): Promise<StaffBookingWindow[]> => {
const response = await client.get<StaffBookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS,
);
return unwrap(response.data);
},
updateGlobalRules: async (
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
): Promise<TrainSchedulingGlobalRules> => {

View File

@@ -35,6 +35,9 @@ export interface Vehicle {
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
locationId?: string | null;
/** Odometer-derived distances (API sends numeric strings; coerce with Number). */
estimatedDistanceKm?: number | null;
actualDistanceKm?: number | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,46 @@
import { api as apiClient } from '../auth/http';
export interface FaydaStartResponse {
authorizationUrl: string;
}
export interface FaydaCompleteResult {
purpose: 'LOGIN' | 'VERIFY';
verified: boolean;
fullName?: string;
email?: string;
phoneNumber?: string;
/** ISO yyyy-MM-dd */
birthdate?: string;
gender?: string;
iamUserId?: string;
userDataSaved?: boolean;
}
/** Message posted from the /callback popup back to the opener window. */
export interface FaydaCallbackMessage {
type: 'fayda-callback';
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
export const verifaydaService = {
/** Returns the eSignet authorize URL to open in a popup. */
start: () =>
apiClient
.post<FaydaStartResponse>('/fayda/verification/start', {
purpose: 'VERIFY',
platform: 'WEB',
})
.then((r) => r.data),
/** Exchange the callback code+state for the verified identity attributes. */
complete: (code: string, state: string) =>
apiClient
.get<FaydaCompleteResult>(
`/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
)
.then((r) => r.data),
};

View File

@@ -69,9 +69,22 @@ export interface BookingCompany {
website?: string | null;
}
/** One physical container under a line — its own number + verified gross mass. */
export interface BookingContainerUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
sortOrder?: number;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
/** Line-level number — often a "TBD-…" placeholder; real numbers live in units. */
containerNumber?: string | null;
quantity: number;
vgmPerUnitTons: number;
containerType?: {
@@ -80,6 +93,8 @@ export interface BookingContainerLine {
label?: string;
sizeFt?: number;
};
/** Per-physical-container rows (number + weight). Empty when not captured. */
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {
@@ -178,6 +193,9 @@ export interface BookingDetail {
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
contractReference?: string | null;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
@@ -188,7 +206,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -205,6 +223,7 @@ export interface BookingDetail {
export interface BookingListRow {
id: string;
reference: string;
contractReference?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;

View File

@@ -226,6 +226,27 @@ export interface BatchBoardBooking {
state: BatchBoardBookingState;
}
/**
* An announced booking window on any lane (import cycle or export FCFS), for
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
*/
export interface StaffBookingWindow {
scheduleId: string;
trainNumber: string | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: BookingWindowPhase | string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;
@@ -325,6 +346,25 @@ export interface BatchBoardScheduleDetail {
allocationViolations: string[];
}
/**
* A booking window for one of a contract's routes/schedules. `isOpenNow === true`
* means a booking may be created right now for that route. Times are ISO strings;
* render them in EAT (Africa/Addis_Ababa).
*/
export interface BookingWindow {
scheduleId: string;
direction: string | null;
windowPhase: BookingWindowPhase | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: Array<{ id: string; reference: string; reason: string }>;
@@ -366,6 +406,11 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
route?: {
id: string;
name: string;
@@ -455,6 +500,21 @@ export interface ImportDjiboutiDocumentRecord {
notes?: string | null;
}
export type LoadingStatus = "LOADED" | "UNLOADED";
export interface ImportLoadingBooking {
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
loadingStatus: LoadingStatus;
}
export interface ImportLoadingBookingsResponse {
count: number;
items: ImportLoadingBooking[];
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;

View File

@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
containerId?: string;
goodsId?: string;
status?: InventoryStatus;
direction?: 'IMPORT' | 'EXPORT';
search?: string;
dateFrom?: string;
dateTo?: string;