Recieved invontory detals

This commit is contained in:
Hagernesh
2026-06-12 06:58:21 +00:00
parent c86118cd8d
commit 72e2e3c985
48 changed files with 1818 additions and 279 deletions

View File

@@ -0,0 +1,62 @@
import { Center, Loader, Text, Timeline } from '@mantine/core';
import {
ArrowRightLeft,
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse,
} from 'lucide-react';
import { useInventoryActivity } from '@/hooks/useWarehouses';
import type { ActivityType } from '@/types/warehouse';
import { formatDate, humanizeEnum } from './options';
const activityIcon: Record<ActivityType, React.ReactNode> = {
INVENTORY_RECEIVED: <PackagePlus size={14} />,
INVENTORY_STORED: <Warehouse size={14} />,
INVENTORY_MOVED: <ArrowRightLeft size={14} />,
INVENTORY_RESERVED: <ClipboardCheck size={14} />,
READY_FOR_LOADING: <PackageCheck size={14} />,
INVENTORY_LOADED: <Truck size={14} />,
INVENTORY_DISPATCHED: <Send size={14} />,
};
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryActivity(inventoryId);
const items = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No activity recorded yet.
</Text>
);
}
return (
<Timeline active={items.length} bulletSize={24} lineWidth={2}>
{items.map((log) => (
<Timeline.Item key={log.id} bullet={activityIcon[log.activityType]} title={humanizeEnum(log.activityType)}>
{log.description && (
<Text size="sm" c="dimmed">
{log.description}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{log.performedBy ?? 'system'} · {formatDate(log.createdAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -0,0 +1,41 @@
import { Select } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { bookingsService } from '@/services/bookings.service';
interface BookingSelectProps {
value: string;
onChange: (bookingId: string) => void;
label?: string;
required?: boolean;
/** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */
statuses?: string;
}
/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */
export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) {
const { data, isLoading } = useQuery({
queryKey: ['bookings', 'options', statuses ?? 'all'],
queryFn: () =>
bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items),
});
const options = (data ?? []).map((b) => ({
value: b.id,
label: b.status ? `${b.reference} · ${b.status}` : b.reference,
}));
return (
<Select
label={label}
required={required}
searchable
clearable
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
placeholder={isLoading ? 'Loading bookings…' : 'Search booking reference'}
nothingFoundMessage="No bookings found"
/>
);
}

View File

@@ -27,6 +27,7 @@ interface FormState {
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -37,6 +38,7 @@ const emptyForm = (): FormState => ({
locationName: '',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -58,6 +60,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
maxVolume: warehouse.maxVolume ?? '',
status: warehouse.status,
}
: emptyForm(),
@@ -80,6 +83,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -105,14 +109,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="MODJO-OW"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,7 +143,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
label="Location name"
placeholder="Modjo, Oromia"
value={form.locationName}
onChange={(e) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
/>
<Group grow>
@@ -157,6 +161,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseYardType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_YARD',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: yard.type,
capacityWeight: yard.capacityWeight ?? '',
capacityContainers: yard.capacityContainers ?? '',
maxVolume: yard.maxVolume ?? '',
status: yard.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
placeholder="Container Yard A"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="CY-A"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseZoneType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_ZONE',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: zone.type,
capacityWeight: zone.capacityWeight ?? '',
capacityContainers: zone.capacityContainers ?? '',
maxVolume: zone.maxVolume ?? '',
status: zone.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
placeholder="Zone A-01"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="A-01"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,37 @@
import { Modal, Tabs } from '@mantine/core';
import { ArrowRightLeft, ListChecks } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { ActivityTimeline } from './ActivityTimeline';
import { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
interface InventoryHistoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function InventoryHistoryModal({ opened, onClose, item }: InventoryHistoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory history" centered size="xl">
{item && (
<Tabs defaultValue="activity">
<Tabs.List>
<Tabs.Tab value="activity" leftSection={<ListChecks size={16} />}>
Activity
</Tabs.Tab>
<Tabs.Tab value="movements" leftSection={<ArrowRightLeft size={16} />}>
Movements
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="activity" pt="md">
<ActivityTimeline inventoryId={item.id} />
</Tabs.Panel>
<Tabs.Panel value="movements" pt="md">
<InventoryMovementHistoryTable inventoryId={item.id} />
</Tabs.Panel>
</Tabs>
)}
</Modal>
);
}

View File

@@ -0,0 +1,64 @@
import { Center, Loader, Table, Text } from '@mantine/core';
import { useInventoryMovements } from '@/hooks/useWarehouses';
import { formatDate } from './options';
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}` : '—');
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryMovements(inventoryId);
const movements = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (movements.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No movements recorded for this item.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={640}>
<Table verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>From (W / Y / Z)</Table.Th>
<Table.Th>To (W / Y / Z)</Table.Th>
<Table.Th>Remarks</Table.Th>
<Table.Th>By</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{movements.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>
<Text size="xs">
{shortId(m.fromWarehouseId)} / {shortId(m.fromYardId)} / {shortId(m.fromZoneId)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">
{shortId(m.toWarehouseId)} / {shortId(m.toYardId)} / {shortId(m.toZoneId)}
</Text>
</Table.Td>
<Table.Td>{m.remarks ?? '—'}</Table.Td>
<Table.Td>{m.movedBy ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(m.movedAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useDispatchInventory,
useLoadInventory,
useMarkReadyForLoading,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
try {
await fn();
toast({ title: label });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
switch (action) {
case 'store':
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
case 'reserve':
setReserveItem(item);
return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
return runDirect(item, () => loadMutation.mutateAsync(item.id), 'Inventory loaded');
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
default:
return;
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<InventoryHistoryModal
opened={Boolean(historyItem)}
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface MoveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
const { toast } = useToast();
const moveMutation = useMoveInventory();
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
setRemarks('');
}
}, [opened]);
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(warehouseId || undefined);
const zonesQuery = useWarehouseZones(yardId || undefined);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const handleSubmit = async () => {
if (!item) return;
if (!warehouseId || !yardId || !zoneId) {
toast({ variant: 'destructive', title: 'Select destination warehouse, yard and zone' });
return;
}
try {
await moveMutation.mutateAsync({
id: item.id,
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
});
toast({ title: 'Inventory moved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
<Stack gap="md">
<Select
label="Destination warehouse"
placeholder="Select warehouse"
required
searchable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Destination yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
required
searchable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Destination zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
required
searchable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Textarea
label="Remarks"
placeholder="Reason for the move"
autosize
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
Move inventory
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -9,6 +9,7 @@ import {
useWarehouses,
} from '@/hooks/useWarehouses';
import type { ReceiveInventoryPayload } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReceiveInventoryModalProps {
@@ -125,12 +126,10 @@ export function ReceiveInventoryModal({
{bookingId ? (
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
) : (
<TextInput
label="Booking ID"
placeholder="Booking UUID"
required
<BookingSelect
label="Booking"
value={form.bookingId}
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
/>
)}
@@ -198,7 +197,7 @@ export function ReceiveInventoryModal({
autosize
minRows={2}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
/>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReserveInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReserveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
const { toast } = useToast();
const reserveMutation = useReserveInventory();
const [bookingId, setBookingId] = useState('');
useEffect(() => {
if (opened) setBookingId(item?.bookingId ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!bookingId.trim()) {
toast({ variant: 'destructive', title: 'Booking is required' });
return;
}
try {
await reserveMutation.mutateAsync({ inventoryId: item.id, bookingId: bookingId.trim() });
toast({ title: 'Inventory reserved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Reserve inventory" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">The booking must be in <b>PAID</b> status and the inventory must be <b>STORED</b>.</Text>
</Alert>
<BookingSelect label="Booking (PAID)" required statuses="PAID" value={bookingId} onChange={setBookingId} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={reserveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={reserveMutation.isPending}>
Reserve
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,15 +1,17 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, History } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -19,11 +21,20 @@ const itemKind = (item: WarehouseInventoryItem) => {
return { label: '—', color: 'gray' };
};
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
};
export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
busyId,
onAdvance,
onMove,
onHistory,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -34,7 +45,7 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
@@ -47,7 +58,6 @@ export function WarehouseInventoryTable({
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -55,14 +65,21 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
</Tooltip>
)}
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
@@ -80,33 +97,31 @@ export function WarehouseInventoryTable({
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.readyForLoadingAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="cyan"
leftSection={<ClipboardCheck size={14} />}
disabled={item.status !== 'ARRIVED_AT_WAREHOUSE' || busy}
loading={busy}
onClick={() => onInspect(item)}
>
Inspect
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<PackageCheck size={14} />}
disabled={item.status !== 'UNDER_INSPECTION' || busy}
loading={busy}
onClick={() => onReadyForLoading(item)}
>
Ready
</Button>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

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

View File

@@ -11,3 +11,10 @@ export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';
export { ReserveInventoryModal } from './ReserveInventoryModal';
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect';