Warehouse

This commit is contained in:
Hagernesh
2026-06-17 12:44:57 +00:00
parent d5a536956f
commit f528b75737
26 changed files with 790 additions and 51 deletions

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
@@ -11,29 +12,36 @@ import {
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
/** Brand palette: alternating orange + light green. */
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
interface Metric {
key: keyof WarehouseDashboard;
label: string;
color: string;
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', color: 'indigo', icon: <WarehouseIcon size={20} /> },
{ key: 'totalInventory', label: 'Total Inventory', color: 'gray', icon: <Boxes size={20} /> },
{ key: 'receivedToday', label: 'Received Today', color: 'yellow', icon: <PackagePlus size={20} /> },
{ key: 'stored', label: 'Stored', color: 'blue', icon: <Layers size={20} /> },
{ key: 'reserved', label: 'Reserved', color: 'grape', icon: <ClipboardCheck size={20} /> },
{ key: 'readyForLoading', label: 'Ready For Loading', color: 'cyan', icon: <PackageCheck size={20} /> },
{ key: 'loaded', label: 'Loaded', color: 'teal', icon: <Truck size={20} /> },
{ key: 'dispatched', label: 'Dispatched', color: 'green', icon: <Send size={20} /> },
{ 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: '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 },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isLoading } = useWarehouseDashboard();
return (
@@ -53,25 +61,53 @@ export default function WarehouseDashboardPage() {
<Loader />
</Center>
) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card key={metric.key} withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start">
<Card
key={metric.key}
radius="lg"
padding="lg"
onClick={() => navigate(metric.to)}
style={{
cursor: 'pointer',
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
border: `1px solid ${metric.theme.border}`,
transition: 'box-shadow 150ms ease, transform 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '';
e.currentTarget.style.transform = '';
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={700} size="28px" mt={6}>
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon variant="light" color={metric.color} size="lg" radius="md">
<ThemeIcon
variant="filled"
size={46}
radius="md"
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
</Container>

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
@@ -18,7 +19,11 @@ import {
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>({});
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);