mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 & 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 & book
|
||||
@@ -846,6 +1101,8 @@ export default function GlCreateBookingForm() {
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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…",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
|
||||
containerId?: string;
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
direction?: 'IMPORT' | 'EXPORT';
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
|
||||
Reference in New Issue
Block a user