Merge branch 'freight/develop' into freight/style/ui-sync

This commit is contained in:
Nathnael Wondisha
2026-06-22 11:58:18 +03:00
committed by GitHub
117 changed files with 8031 additions and 7010 deletions

View File

@@ -49,6 +49,8 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
@@ -213,34 +215,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
permission: FREIGHT_PERMS.admin,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
@@ -341,216 +315,210 @@ const App = () => {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path='/um/*' element={<UserManagementHostPage />} />
<Route path="*" element={<Navigate to="/um" replace />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* iframe-based user management module */}
<Route path="um/*" element={<UserManagementHostPage />} />
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/um" replace />} />
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
</Route>
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};

View File

@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useDeliverInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface DeliverInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
const { toast } = useToast();
const deliverMutation = useDeliverInventory();
const [receiverName, setReceiverName] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setReceiverName('');
setRemarks('');
}
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!receiverName.trim()) {
toast({ variant: 'destructive', title: 'Receiver name is required' });
return;
}
try {
await deliverMutation.mutateAsync({
id: item.id,
payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined },
});
toast({ title: 'Delivered — proof of delivery captured' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Deliver to customer (proof of delivery)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
A release order must already be issued. Capturing the receiver marks the goods <b>DELIVERED</b>.
</Text>
</Alert>
<TextInput
label="Receiver name"
required
placeholder="Who received the goods"
value={receiverName}
onChange={(e) => setReceiverName(e.currentTarget.value)}
/>
<Textarea
label="Remarks"
placeholder="Optional delivery notes"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={deliverMutation.isPending}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={deliverMutation.isPending}>
Confirm delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,18 +1,23 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
import { ClipboardCheck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
@@ -20,10 +25,12 @@ import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
@@ -32,10 +39,46 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: { inspectedCount: number; skippedCount: number };
};
const r = res.data;
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
@@ -63,6 +106,14 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return;
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
case 'ready-for-pickup':
return runDirect(item, () => pickupMutation.mutateAsync(item.id), 'Ready for pickup');
case 'release':
setReleaseItem(item);
return;
case 'deliver':
setDeliverItem(item);
return;
default:
return;
}
@@ -78,15 +129,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b>
</Text>
<Button
size="compact-sm"
variant="light"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
allSelected={allSelected}
someSelected={someSelected}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
@@ -110,6 +185,8 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setFeeItem(null)}
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
</>
);
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface ReleaseOrderModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useReleaseInventory();
const [reference, setReference] = useState('');
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
try {
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
toast({ title: 'Release order issued' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Records the delivery order / release order sent to the customer. Once issued, the goods can be
picked up and delivered.
</Text>
</Alert>
<TextInput
label="Release order reference"
placeholder="e.g. DO-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
Issue release order
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps {
}
const ORANGE = '#f08c00';
const GREEN = '#5bbf4a';
const GREEN = '#22c55e'; // green from bookings
/** Inventory lifecycle status series — alternating orange / light green. */
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
const STATUS_SERIES = [
{ key: 'stored', label: 'Stored', color: ORANGE },
{ key: 'reserved', label: 'Reserved', color: GREEN },
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
{ key: 'loaded', label: 'Loaded', color: GREEN },
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
] as const;
type Granularity = 'week' | 'month' | 'year';
@@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry, i) => (
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip />

View File

@@ -4,7 +4,7 @@ import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
@@ -16,6 +16,14 @@ interface WarehouseInventoryTableProps {
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void;
allSelected?: boolean;
someSelected?: boolean;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -31,6 +39,9 @@ const actionColor: Record<InventoryAction, string> = {
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
};
export function WarehouseInventoryTable({
@@ -41,6 +52,12 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [

View File

@@ -34,12 +34,14 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'edr-green',
DELIVERED: 'edr-green',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {

View File

@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>
@@ -302,6 +303,27 @@ export const URL_CONSTANTS = {
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
// Import branch
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) =>
direction
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
},
WAREHOUSE_LOADINGS: {

View File

@@ -1,3 +1,4 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
// API host is env-driven (set VITE_API_URL per environment, e.g. the remote
// https://edrfreightapi.triaplc.com for prod). Falls back to the local API for dev.
export const API_BASE_URL =
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:3001';

View File

@@ -132,6 +132,29 @@ export const useBookableSchedules = (
enabled: Boolean(originYardId && destinationYardId),
});
/**
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
* day (not a train) when creating a booking; the engine assigns the train.
*/
export const useAvailableDays = (
originYardId?: string | null,
destinationYardId?: string | null,
) =>
useQuery({
queryKey: [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days",
originYardId ?? "",
destinationYardId ?? "",
],
queryFn: () =>
trainSchedulingService.getAvailableDays(
originYardId ?? undefined,
destinationYardId ?? undefined,
),
enabled: Boolean(originYardId && destinationYardId),
});
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),

View File

@@ -12,6 +12,10 @@ import type {
LoadInventoryPayload,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
BulkReceivePayload,
BulkInspectPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -172,6 +176,97 @@ export const useMoveInventory = () =>
warehouseService.move(args.id, args.payload),
);
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
export const useMarkReadyForPickup = () =>
useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id));
export const useReleaseInventory = () =>
useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) =>
warehouseService.release(args.id, args.payload),
);
export const useDeliverInventory = () =>
useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) =>
warehouseService.deliver(args.id, args.payload),
);
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
/**
* All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call.
* Both Receive tabs share this single query (same key) — only one HTTP request fires —
* then filter client-side by direction.
*/
export function useEligibleBookings(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'eligible-bookings'],
queryFn: () => warehouseService.eligibleBookings().then((r) => r.data),
enabled,
});
}
export const useBulkReceive = () =>
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
export const useLoadPassedExport = () =>
useInventoryMutation(() => warehouseService.loadPassedExport());
export const useBulkMarkInspected = () =>
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
export function useReadyToLoadExport(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'ready-to-load-export'],
queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data),
enabled,
});
}
export function useLoadedExport(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'loaded-export'],
queryFn: () => warehouseService.loadedExport().then((r) => r.data),
enabled,
});
}
export const useBulkDispatchExport = () =>
useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds));
/** Arrived IMPORT trains (route-derived). Read-only. */
export function useImportArriveQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-arrive-queue'],
queryFn: () => warehouseService.importArriveQueue().then((r) => r.data),
enabled,
});
}
/** Assigned bookings/items for an arrived import train. Read-only. */
export function useImportTrainItems(scheduleId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-train-items', scheduleId],
queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data),
enabled: Boolean(scheduleId),
});
}
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
export const useAutoUnloadArrivedBookings = () =>
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
export function useImportUnloadedQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
enabled,
});
}
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
export function useImportPickupReadyQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
enabled,
});
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {

View File

@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
originYardId,
destinationYardId,
);
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
const dayOptions = (availableDays ?? []).map((day) => ({
value: day,
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
}));
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
const hasAvailableDays = (availableDays ?? []).length > 0;
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
const effectiveDepartureIso = selectedSchedule
? new Date(selectedSchedule.scheduleDate).toISOString()
: scheduledDate
? new Date(scheduledDate).toISOString()
: "";
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
const effectiveDepartureIso = scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toISOString()
: "";
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
// Reset the day when the route changes — available days depend on the route.
useEffect(() => {
setTrainScheduleId(null);
setScheduledDay(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
// the train afterwards (same flow as the customer portal).
const departureSatisfied = Boolean(scheduledDay);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
trainScheduleId: trainScheduleId || undefined,
// Day-level pool: no trainScheduleId — the engine assigns the train.
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
value={destinationYardId}
onChange={(v) => {
setDestinationYardId(v);
setTrainScheduleId(null);
setScheduledDay(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
/>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
</Text>
) : null}
<Select
label="Shipment day"
placeholder={
originYardId && destinationYardId
? "Select a day with a departure"
: "Pick origin & destination first"
}
data={dayOptions}
value={scheduledDay}
onChange={setScheduledDay}
searchable
disabled={!originYardId || !destinationYardId || daysLoading}
nothingFoundMessage={
hasAvailableDays ? "No match" : "No departures on this route"
}
description="Pick a day with a departure. The batch engine assigns the train by priority."
/>
<Group grow align="flex-end">
<Select
label="Service type"
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
<Group grow align="flex-start">
{selectedSchedule ? (
<TextInput
label="Departure"
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
readOnly
description="Taken from the selected train schedule"
/>
) : (
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
)}
<TextInput
label="Shipment day"
value={
scheduledDay
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: ""
}
placeholder="Pick a day in the Route section"
readOnly
description="The engine assigns the train on this day"
/>
<Select
label="Payment currency"
data={[

View File

@@ -1,113 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { getCookie } from '@/auth/cookies';
import { useEffect, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
UserManagementApp,
type UserManagementRuntimeOptions,
type UserManagementSessionSeed,
} from "@tria-plc/iamui";
function readToken(): string | null {
return getCookie('auth-token') ?? null;
}
import { getCookie } from "@/auth/cookies";
function readRefreshToken(): string | null {
return getCookie('refresh-token') ?? null;
import { iamConfig } from "./iamConfig";
function readInitialSession(): UserManagementSessionSeed | null {
const token = getCookie("auth-token");
if (!token) {
return null;
}
const refreshToken = getCookie("refresh-token") ?? undefined;
return {
token,
refreshToken,
rememberMe: true,
};
}
export default function UserManagementHostPage() {
const navigate = useNavigate();
const location = useLocation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const mountRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<Root | null>(null);
const unmountTimerRef = useRef<number | null>(null);
const mountBase = (
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
).replace(/\/$/, '');
const moduleOrigin = window.location.origin;
const [iframeSrc] = useState(() => {
const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
return mountBase + (sub || '/') + location.search;
});
// ✅ Send token when iframe loads
const handleIframeLoad = () => {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (!token) {
console.warn('⚠️ No authentication token found');
return;
}
if (!target) {
console.warn('⚠️ No iframe reference');
return;
}
target.postMessage(
{
type: 'UM_AUTH_TOKEN',
token,
refreshToken,
},
moduleOrigin
);
console.log('✅ Token sent to iframe module');
};
// ✅ Listen for messages from iframe
useEffect(() => {
const onMessage = (event: MessageEvent) => {
// Security: Only accept from same origin
if (event.origin !== moduleOrigin) {
console.warn('🚫 Blocked message from different origin:', event.origin);
return;
}
const mountNode = mountRef.current;
const data = event.data as { type?: string; path?: string } | undefined;
if (!data) return;
if (!mountNode) {
return;
}
// Handle auth request (if module asks for token again)
if (data.type === 'UM_REQUEST_AUTH') {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (unmountTimerRef.current !== null) {
window.clearTimeout(unmountTimerRef.current);
unmountTimerRef.current = null;
}
if (token && target) {
target.postMessage(
{
type: 'UM_AUTH_TOKEN',
token,
refreshToken,
},
moduleOrigin
);
console.log('✅ Token resent to iframe (on request)');
}
return;
}
if (!rootRef.current) {
rootRef.current = createRoot(mountNode);
}
// Handle route synchronization
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
const target = '/dashboard/um' + data.path;
if (window.location.pathname + window.location.search !== target) {
navigate(target, { replace: true });
}
}
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
const iamApiUrl = "/um-api";
const runtime: UserManagementRuntimeOptions = {
basename: "/um",
apiBaseUrl,
apiUrl: iamApiUrl,
recordApiUrl: iamApiUrl,
chronicleUrl: iamApiUrl,
auditApiUrl: iamApiUrl,
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [moduleOrigin, navigate]);
rootRef.current.render(
<UserManagementApp
config={iamConfig}
runtime={runtime}
session={{
initialSession: readInitialSession(),
enableEmbeddedAuthBridge: false,
}}
/>,
);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<iframe
ref={iframeRef}
title="User Management"
src={iframeSrc}
onLoad={handleIframeLoad}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
</div>
);
return () => {
unmountTimerRef.current = window.setTimeout(() => {
rootRef.current?.unmount();
rootRef.current = null;
unmountTimerRef.current = null;
}, 0);
};
}, []);
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
}

View File

@@ -0,0 +1,208 @@
import type { DesignConfig } from "@tria-plc/iamui";
import {
FREIGHT_BRAND,
FREIGHT_BRAND_DARK,
FREIGHT_BRAND_LIGHT,
freightBrand,
} from "@/theme/freight-brand";
export const iamConfig: DesignConfig = {
brand: {
appName: "EDR Freight Backoffice",
logoUrl: "/assets/logo.svg",
},
colors: {
primary: FREIGHT_BRAND,
primaryForeground: "#ffffff",
secondary: "#f4f7fb",
background: "#f7f9fb",
foreground: "#0f172a",
border: "#eef1f4",
muted: "#f1f5f9",
mutedForeground: "#64748b",
card: "#ffffff",
sidebar: "#ffffff",
danger: "#ef4444",
},
typography: {
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
baseFontSize: "15px",
fontWeight: "500",
},
shape: {
radius: "1rem",
},
shadows: {
card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
},
components: {
buttonDefaultVariant: "filled",
inputDefaultSize: "sm",
inputRadius: "md",
modalRadius: "lg",
tableHighlightOnHover: true,
},
layout: {
userManagementView: "classic",
showTopBar: true,
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
headerHeight: "80px",
contentMaxWidth: "none",
sidebarBackground: "#ffffff",
sidebarColor: "#475569",
sidebarMutedColor: "#94a3b8",
sidebarActiveBackground:
"linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
sidebarActiveColor: FREIGHT_BRAND,
sidebarHoverBackground: "#f5f7fa",
sidebarBorder: "#eef1f4",
sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
sidebarBrandLabel: "EDR Freight",
sidebarBrandSublabel: "Backoffice Console",
menuBackground: "#ffffff",
menuActiveColor: FREIGHT_BRAND,
menuActiveBorderColor: FREIGHT_BRAND,
menuColor: "#64748b",
menuHoverColor: "#0f172a",
modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
modalHeaderBackground: "#ffffff",
modalHeaderEditBackground: "#ffffff",
modalIconBackground: freightBrand.mutedBg,
modalIconColor: FREIGHT_BRAND,
modalTitleColor: "#0f172a",
modalFocusColor: FREIGHT_BRAND,
modalSurface: "#ffffff",
},
appearance: {
colorScheme: "light",
slots: {
root: {
styles: {
background: "#f7f9fb",
color: "#0f172a",
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
},
},
shell: {
styles: {
background: "#f7f9fb",
},
},
content: {
styles: {
background: "#f7f9fb",
},
},
page: {
styles: {
background: "#ffffff",
border: "1px solid #eef1f4",
borderRadius: "24px",
boxShadow:
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
},
},
card: {
styles: {
background: "#ffffff",
border: "1px solid #eef1f4",
borderRadius: "20px",
boxShadow:
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
},
},
sidebar: {
styles: {
background: "#ffffff",
border: "1px solid #eef1f4",
borderRadius: "16px",
boxShadow:
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
},
},
"sidebar-brand": {
styles: {
minHeight: "80px",
borderBottom: "1px solid #f1f5f9",
},
},
topbar: {
styles: {
background: "#ffffff",
border: "1px solid #eef1f4",
borderRadius: "16px",
boxShadow:
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
},
},
"topbar-panel": {
styles: {
background: "#f7f9fb",
border: "1px solid #eef1f4",
borderRadius: "12px",
},
},
"topbar-user-summary": {
styles: {
borderRadius: "14px",
},
},
table: {
styles: {
background: "#ffffff",
border: "1px solid #eef1f4",
borderRadius: "20px",
overflow: "hidden",
},
},
"table-header": {
styles: {
background: "#f8fafc",
},
},
modal: {
styles: {
borderRadius: "24px",
overflow: "hidden",
},
},
"modal-header": {
styles: {
background: "#ffffff",
borderBottom: "1px solid #eef1f4",
},
},
},
customCss: `
[data-um-app="user-management"] {
--um-page-gap: 20px;
}
[data-um-app="user-management"] h1,
[data-um-app="user-management"] h2,
[data-um-app="user-management"] h3,
[data-um-app="user-management"] h4,
[data-um-app="user-management"] h5,
[data-um-app="user-management"] h6 {
letter-spacing: -0.02em;
color: #0f172a;
}
[data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
}
[data-um-app="user-management"] button,
[data-um-app="user-management"] input,
[data-um-app="user-management"] select,
[data-um-app="user-management"] textarea {
font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
}
`,
},
};

View File

@@ -0,0 +1,512 @@
import { useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import {
Badge,
Box,
Breadcrumbs,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Boxes,
ChevronRight,
FileText,
Home,
Layers,
Package,
Pencil,
Plus,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
getRuleEngineResource,
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import {
useRuleEngineList,
useRuleEngineMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
const CARGO_SLUG = "cargo-types";
const BASE_PATH = "/dashboard/configuration/cargo-types";
interface CargoNode extends RuleEngineRecord {
cargoTypeName?: string;
code?: string;
parentGroupId?: string | null;
showFreeTextBox?: boolean;
requiresDirectorApproval?: boolean;
isActive?: boolean;
displayOrder?: number;
}
const str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
/** Create/edit form fields. Parent is set from the current page, never picked. */
const FORM_FIELDS: FormFieldDef[] = [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
const CargoTypesPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const { id: currentId } = useParams<{ id: string }>();
const config = getRuleEngineResource(CARGO_SLUG);
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
// One fetch of the whole (small) set; the tree, ancestry and each level are
// derived client-side so drilling between levels is instant.
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
page: 1,
pageSize: 500,
sortBy: "displayOrder",
sortOrder: "ASC",
});
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
const all = (data?.data ?? []) as CargoNode[];
const { byId, childrenOf } = useMemo(() => {
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
const childrenOf = new Map<string, CargoNode[]>();
for (const node of all) {
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
const key = parentId || "__root__";
const list = childrenOf.get(key) ?? [];
list.push(node);
childrenOf.set(key, list);
}
for (const list of childrenOf.values()) {
list.sort(
(a, b) =>
orderOf(a) - orderOf(b) ||
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
);
}
return { byId, childrenOf };
}, [all]);
// Current node (null at root) and its ancestor chain for the breadcrumb.
const current = currentId ? byId.get(currentId) ?? null : null;
const ancestors = useMemo(() => {
const chain: CargoNode[] = [];
let node = current;
const seen = new Set<string>();
while (node && !seen.has(node.id)) {
chain.unshift(node);
seen.add(node.id);
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
}
return chain;
}, [current, byId]);
const levelKey = current ? current.id : "__root__";
const levelNodes = childrenOf.get(levelKey) ?? [];
const term = search.trim().toLowerCase();
const matches = (n: CargoNode) =>
!term ||
str(n.cargoTypeName).toLowerCase().includes(term) ||
str(n.code).toLowerCase().includes(term);
const visibleNodes = useMemo(
() => (term ? levelNodes.filter(matches) : levelNodes),
[levelNodes, term],
);
if (!config) return <Navigate to="/dashboard/overview" replace />;
if (!canView) return <Navigate to="/dashboard/overview" replace />;
// A bad/stale :id (after data loads) → fall back to the root list.
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
const atRoot = !current;
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
const handleSubmit = (values: Record<string, unknown>) => {
const payload: Record<string, unknown> = { ...values };
// Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id;
}
const done = () => setFormMode(null);
if (formMode?.kind === "edit") {
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
} else {
create.mutate(payload, { onSuccess: done });
}
};
const addLabel = atRoot ? "Add category" : "Add cargo type";
return (
<Stack gap="lg">
{/* ── Header ─────────────────────────────────────────────── */}
<Card
p="lg"
radius="lg"
withBorder
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
>
{/* Breadcrumb */}
<Breadcrumbs
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
mb="md"
>
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
<Group gap={5} wrap="nowrap">
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
Cargo Types
</Text>
</Group>
</UnstyledButton>
{ancestors.map((node, i) => {
const isLast = i === ancestors.length - 1;
return (
<UnstyledButton
key={node.id}
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
style={{ cursor: isLast ? "default" : "pointer" }}
>
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
{str(node.cargoTypeName) || "Untitled"}
</Text>
</UnstyledButton>
);
})}
</Breadcrumbs>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={48} radius="md" variant="light" color="teal">
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={22} c="dark.8" truncate>
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
</Text>
{!atRoot && current?.code ? (
<Badge variant="default" radius="sm">
{str(current.code)}
</Badge>
) : null}
{!atRoot && current?.isActive === false ? (
<Badge variant="light" color="gray" radius="sm">
Inactive
</Badge>
) : null}
</Group>
<Text fz={13} c="dimmed" mt={2}>
{atRoot
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
</Text>
</Box>
</Group>
<Group gap="sm" wrap="nowrap">
<TextInput
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search this level…"
leftSection={<Search size={16} />}
w={240}
/>
{canManage && (
<Button
color="teal"
leftSection={<Plus size={16} />}
onClick={() => setFormMode({ kind: "create" })}
>
{addLabel}
</Button>
)}
</Group>
</Group>
</Card>
{/* ── Level list ─────────────────────────────────────────── */}
<Card
p={0}
radius="lg"
withBorder
style={{
background: "white",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
overflow: "hidden",
}}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader color="teal" />
</Group>
) : isError ? (
<Text p="xl" c="red" ta="center">
Failed to load cargo types.
</Text>
) : visibleNodes.length === 0 ? (
<Stack align="center" gap="sm" py={56}>
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
<Package size={26} />
</ThemeIcon>
<Text fw={600} c="dark.6">
{term
? "Nothing matches your search"
: atRoot
? "No cargo categories yet"
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
</Text>
{!term && canManage && (
<Button
variant="light"
color="teal"
leftSection={<Plus size={16} />}
onClick={() => setFormMode({ kind: "create" })}
>
{atRoot ? "Add your first category" : "Add the first cargo type"}
</Button>
)}
</Stack>
) : (
<Stack gap={0}>
{visibleNodes.map((node, i) => (
<CargoRow
key={node.id}
node={node}
childCount={(childrenOf.get(node.id) ?? []).length}
topBorder={i > 0}
canManage={canManage}
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
onEdit={() => setFormMode({ kind: "edit", record: node })}
onDelete={() => setDeleteTarget(node)}
/>
))}
</Stack>
)}
</Card>
{/* ── Create / edit dialog ───────────────────────────────── */}
<RuleEngineFormDialog
open={Boolean(formMode)}
onOpenChange={(open) => {
if (!open) setFormMode(null);
}}
title={
formMode?.kind === "edit"
? `Edit ${str(formMode.record.cargoTypeName)}`
: atRoot
? "Add category"
: `Add cargo under “${str(current?.cargoTypeName)}`
}
description={
formMode?.kind === "edit"
? "Update this cargo type."
: atRoot
? "Create a top-level cargo category."
: "Create a cargo type inside this category. It's attached here automatically."
}
fields={FORM_FIELDS}
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
isSubmitting={create.isPending || update.isPending}
onSubmit={handleSubmit}
/>
{/* ── Delete confirm ─────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Delete cargo type?"
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
<>
<Text span fw={600}>
{str(deleteTarget?.cargoTypeName)}
</Text>{" "}
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
leaves them without a category. Continue?
</>
) : (
<>
This will delete{" "}
<Text span fw={600}>
{str(deleteTarget?.cargoTypeName)}
</Text>
.
</>
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={remove.isPending}
onClick={() => {
if (!deleteTarget) return;
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
// ── A single cargo row — drills into its own page on click ──────────────────
interface CargoRowProps {
node: CargoNode;
childCount: number;
topBorder: boolean;
canManage: boolean;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
}
function CargoRow({
node,
childCount,
topBorder,
canManage,
onOpen,
onEdit,
onDelete,
}: CargoRowProps) {
const inactive = node.isActive === false;
const hasChildren = childCount > 0;
return (
<Group
justify="space-between"
wrap="nowrap"
px="lg"
py="md"
style={{
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
transition: "background 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
}}
>
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={650} fz={15} c="dark.8" truncate>
{str(node.cargoTypeName) || "Untitled"}
</Text>
{node.code ? (
<Badge size="xs" variant="default" radius="sm">
{str(node.code)}
</Badge>
) : null}
{node.requiresDirectorApproval ? (
<Tooltip label="Requires director approval" withArrow>
<Badge
size="xs"
variant="light"
color="orange"
radius="sm"
leftSection={<ShieldCheck size={11} />}
>
Approval
</Badge>
</Tooltip>
) : null}
{node.showFreeTextBox ? (
<Tooltip label="Shows a free-text box on booking" withArrow>
<Badge
size="xs"
variant="light"
color="blue"
radius="sm"
leftSection={<FileText size={11} />}
>
Free text
</Badge>
</Tooltip>
) : null}
{inactive ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Inactive
</Badge>
) : null}
</Group>
<Text fz={12.5} c="dimmed" mt={2}>
{hasChildren
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
: "No cargo types inside yet — open to add"}
</Text>
</Box>
</Group>
</UnstyledButton>
<Group gap={4} wrap="nowrap">
{canManage && (
<>
<Tooltip label="Edit" withArrow>
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
<Pencil size={15} />
</Button>
</Tooltip>
<Tooltip label="Delete" withArrow>
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
<Trash2 size={15} />
</Button>
</Tooltip>
</>
)}
<Tooltip label="Open" withArrow>
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
<ChevronRight size={18} />
</Button>
</Tooltip>
</Group>
</Group>
);
}
export default CargoTypesPage;

View File

@@ -2,8 +2,12 @@ import { useNavigate } from 'react-router-dom';
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
ShieldCheck,
PackageCheck,
PackagePlus,
PackageSearch,
CircleCheck,
Send,
Truck,
Warehouse as WarehouseIcon,
@@ -25,14 +29,18 @@ interface Metric {
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory' },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED' },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue' },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
];
export default function WarehouseDashboardPage() {

View File

@@ -109,6 +109,21 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
* a day; the batch engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
originYardId?: string,
destinationYardId?: string,
): Promise<string[]> => {
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: { originYardId, destinationYardId } },
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),

View File

@@ -27,6 +27,20 @@ import type {
LoadInventoryPayload,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
EligibleBooking,
BulkReceivePayload,
BulkReceiveResult,
LoadPassedExportResult,
BulkInspectPayload,
BulkInspectResult,
ReadyToLoadRow,
BulkDispatchResult,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
AutoUnloadArrivedResult,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -104,6 +118,45 @@ export const warehouseService = {
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
dispatch: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────
markReadyForPickup: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
release: (id: string, payload: ReleaseOrderPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
receiveBulk: (payload: BulkReceivePayload) =>
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
loadPassedExport: () =>
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
bulkMarkInspected: (payload: BulkInspectPayload) =>
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
readyToLoadExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
loadedExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADED_EXPORT),
bulkDispatchExport: (inventoryIds: string[]) =>
apiClient.post<BulkDispatchResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_DISPATCH_EXPORT, {
inventoryIds,
}),
importArriveQueue: () =>
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
importTrainItems: (scheduleId: string) =>
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
autoUnloadArrivedBookings: (scheduleId: string) =>
apiClient.post<AutoUnloadArrivedResult>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
{ scheduleId },
),
importUnloadedQueue: () =>
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
importPickupReadyQueue: () =>
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_PICKUP_READY_QUEUE),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>

View File

@@ -23,26 +23,72 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
// Import branch
'READY_FOR_PICKUP',
'DELIVERED',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
/** Next allowed lifecycle action keyed by current status. */
export type InventoryAction =
| 'store'
| 'reserve'
| 'ready-for-loading'
| 'load'
| 'dispatch'
// Import branch
| 'ready-for-pickup'
| 'release'
| 'deliver';
/**
* Default next lifecycle action keyed by current status. The RECEIVED and
* READY_FOR_PICKUP rows are direction/release dependent — use
* {@link getNextInventoryAction} which resolves those at runtime.
*/
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
UNLOADED: 'store',
RECEIVED: 'store',
STORED: 'reserve',
RESERVED: 'ready-for-loading',
READY_FOR_LOADING: 'load',
LOADED: 'dispatch',
DISPATCHED: null,
READY_FOR_PICKUP: 'release',
DELIVERED: null,
};
export type InventoryAction = 'store' | 'reserve' | 'ready-for-loading' | 'load' | 'dispatch';
/**
* Resolve the next action for an inventory item, accounting for trade
* direction, the inspection gate, and whether a release order was issued.
* Returns null when no advance button should be shown (e.g. awaiting inspection).
*/
export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryAction | null {
const inspected = item.inspectionStatus === 'PASSED';
const isImport = item.booking?.tradeDirection === 'IMPORT';
switch (item.status) {
case 'UNLOADED':
case 'RECEIVED':
// Import goods skip storage; they need inspection before pickup.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return 'store';
case 'RESERVED':
// Export loading is gated on a passed inspection.
return inspected ? 'ready-for-loading' : null;
case 'READY_FOR_PICKUP':
// Issue the DO / release order first, then hand over the goods.
return item.releaseDate ? 'deliver' : 'release';
default:
return INVENTORY_NEXT_ACTION[item.status];
}
}
export interface WarehouseZone {
id: string;
@@ -136,6 +182,7 @@ export interface WarehouseInventoryItem {
weight: number;
volume: number | null;
status: InventoryStatus;
inspectionStatus: string | null;
arrivedAt: string | null;
storedAt: string | null;
reservedAt: string | null;
@@ -143,6 +190,11 @@ export interface WarehouseInventoryItem {
readyForLoadingAt: string | null;
loadedAt: string | null;
dispatchedAt: string | null;
// Import branch
readyForPickupAt: string | null;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
@@ -156,6 +208,9 @@ export interface InventoryBookingRef {
reference?: string | null;
status?: string | null;
paymentStatus?: string | null;
tradeDirection?: string | null;
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
lastMileDeliveryAddress?: string | null;
}
export interface InventoryMovement {
@@ -197,11 +252,15 @@ export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
awaitingInspection: number;
inspected: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
readyForPickup: number;
delivered: number;
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
@@ -265,6 +324,139 @@ export interface ReserveInventoryPayload {
inventoryId: string;
}
/** Import branch: DO / release order sent to the customer. */
export interface ReleaseOrderPayload {
reference?: string;
releaseDate?: string;
}
/** Import branch: proof of delivery captured on customer pickup. */
export interface DeliverInventoryPayload {
receiverName: string;
deliveredAt?: string;
remarks?: string;
}
/** Receive (Import/Export) bulk flow. */
export interface EligibleBooking {
id: string;
reference: string;
customerId: string | null;
customer: string | null;
direction: string;
origin: string | null;
destination: string | null;
freightType: string | null;
cargo: string | null;
weight: string | null;
paymentStatus: string;
status: string;
}
export interface BulkReceivePayload {
direction: 'IMPORT' | 'EXPORT';
warehouseId: string;
yardId: string;
zoneId: string;
bookingIds: string[];
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface BulkInspectPayload {
inventoryIds: string[];
inspectionType?: string;
remarks?: string;
}
export interface BulkInspectResult {
inspectedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface ReadyToLoadRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
status: string;
}
export interface BulkDispatchResult {
dispatchedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface ImportTrain {
scheduleId: string;
trainNumber: string | null;
route: string | null;
origin: string | null;
destination: string | null;
arrivalTime: string | null;
totalBookings: number;
totalContainers: number;
totalCargoes: number;
status: string;
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportUnloadedItem {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
arrivalTime: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
}
export interface ImportTrainItem {
bookingId: string;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
export interface InventoryInquiryResult {
id: string;
bookingId: string;