mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Warehouse
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user