warehouse

This commit is contained in:
hagiye
2026-06-19 17:13:17 +03:00
parent 63f177e6d0
commit 484ac187a9
22 changed files with 904 additions and 45 deletions

View File

@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateTo?: string;
}

View File

@@ -6,9 +6,14 @@ import { WarehouseYard } from './warehouse-yard.entity';
import { WarehouseZone } from './warehouse-zone.entity';
export const WAREHOUSE_INVENTORY_STATUSES = [
'RECEIVED',
'STORED',
'RESERVED',
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WarehouseYard } from './warehouse-yard.entity';
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
@@ -27,6 +28,10 @@ export class Warehouse extends BaseEntity {
@Column({ name: 'station_id', type: 'uuid', nullable: true })
stationId?: string | null;
@ManyToOne(() => Yard, { nullable: true })
@JoinColumn({ name: 'station_id' })
facility?: Yard | null;
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
locationName?: string | null;

View File

@@ -43,6 +43,24 @@ export class WarehouseInventoryController {
return this.inventoryService.move(id, dto);
}
@Get('dashboard/summary')
@ApiOperation({ summary: 'Warehouse dashboard summary' })
dashboardSummary(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.dashboardSummary(filter);
}
@Post(':id/store')
@ApiOperation({ summary: 'Store received inventory' })
store(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.store(id);
}
@Post(':id/reserve')
@ApiOperation({ summary: 'Reserve stored inventory against a paid booking' })
reserve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: { bookingId?: string }) {
return this.inventoryService.reserve(id, dto);
}
@Patch(':id/inspect')
@ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' })
inspect(@Param('id', ParseUUIDPipe) id: string) {
@@ -54,4 +72,16 @@ export class WarehouseInventoryController {
readyForLoading(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.readyForLoading(id);
}
@Post(':id/load')
@ApiOperation({ summary: 'Mark ready inventory as loaded' })
load(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.load(id);
}
@Post(':id/dispatch')
@ApiOperation({ summary: 'Dispatch loaded inventory' })
dispatch(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.dispatch(id);
}
}

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -30,6 +30,17 @@ export interface InventoryInquiryResult {
readyForLoadingAt: Date | null;
}
export interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -39,7 +50,16 @@ export class WarehouseInventoryService {
// ── Listing ────────────────────────────────────────────────────────────
findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
const createdAt =
filter.dateFrom && filter.dateTo
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
: filter.dateFrom
? MoreThanOrEqual(new Date(filter.dateFrom))
: filter.dateTo
? LessThanOrEqual(new Date(filter.dateTo))
: undefined;
const base = {
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
...(filter.yardId ? { yardId: filter.yardId } : {}),
@@ -49,6 +69,8 @@ export class WarehouseInventoryService {
...(filter.containerId ? { containerId: filter.containerId } : {}),
...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
...(filter.status ? { status: filter.status } : {}),
...(createdAt ? { createdAt } : {}),
...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
};
const search = filter.search?.trim();
@@ -56,11 +78,13 @@ export class WarehouseInventoryService {
? { ...base, notes: ILike(`%${search}%`) }
: base;
return this.inventoryRepository.findAll({
const items = await this.inventoryRepository.findAll({
where,
relations: { warehouse: true, yard: true, zone: true },
relations: { warehouse: { facility: true }, yard: true, zone: true },
order: { createdAt: 'DESC' },
});
await this.attachBookingSummaries(items);
return items;
}
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
@@ -69,7 +93,7 @@ export class WarehouseInventoryService {
async findById(id: string): Promise<WarehouseInventory> {
const item = await this.inventoryRepository.findById(id, {
relations: { warehouse: true, yard: true, zone: true },
relations: { warehouse: { facility: true }, yard: true, zone: true },
});
if (!item) {
@@ -107,7 +131,7 @@ export class WarehouseInventoryService {
quantity: Number(dto.quantity) || 0,
weight,
volume: dto.volume ?? null,
status: 'ARRIVED_AT_WAREHOUSE',
status: 'RECEIVED',
arrivedAt: now,
notes: dto.notes?.trim() ?? null,
}),
@@ -200,12 +224,40 @@ export class WarehouseInventoryService {
return this.findById(id);
}
async store(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'RECEIVED' && item.status !== 'ARRIVED_AT_WAREHOUSE') {
throw new BadRequestException(`Only RECEIVED inventory can be stored (current: ${item.status})`);
}
await this.inventoryRepository.update(id, { status: 'STORED' });
return this.findById(id);
}
async reserve(id: string, dto: { bookingId?: string }): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'STORED') {
throw new BadRequestException('Only STORED inventory can be reserved.');
}
const bookingId = dto.bookingId ?? item.bookingId;
await this.assertPaidBooking(this.dataSource.manager, bookingId);
await this.inventoryRepository.update(id, {
bookingId,
status: 'RESERVED',
});
return this.findById(id);
}
async readyForLoading(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'UNDER_INSPECTION') {
if (item.status !== 'RESERVED' && item.status !== 'UNDER_INSPECTION') {
throw new BadRequestException(
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
`Only RESERVED inventory can be marked READY_FOR_LOADING (current: ${item.status})`,
);
}
@@ -217,6 +269,64 @@ export class WarehouseInventoryService {
return this.findById(id);
}
async load(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'READY_FOR_LOADING') {
throw new BadRequestException(`Only READY_FOR_LOADING inventory can be loaded (current: ${item.status})`);
}
await this.assertPaidBooking(this.dataSource.manager, item.bookingId);
await this.inventoryRepository.update(id, { status: 'LOADED' });
return this.findById(id);
}
async dispatch(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'LOADED') {
throw new BadRequestException(`Only LOADED inventory can be dispatched (current: ${item.status})`);
}
await this.inventoryRepository.update(id, { status: 'DISPATCHED' });
return this.findById(id);
}
async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise<WarehouseDashboardSummary> {
const warehouses = await this.dataSource.getRepository(Warehouse).find({
where: {
status: 'ACTIVE',
...(filter.facilityId ? { stationId: filter.facilityId } : {}),
...(filter.warehouseId ? { id: filter.warehouseId } : {}),
},
});
const inventory = await this.findAll(filter);
const today = new Date();
const byStatus = inventory.reduce<Record<string, number>>((acc, item) => {
acc[item.status] = (acc[item.status] ?? 0) + 1;
return acc;
}, {});
return {
totalWarehouses: warehouses.length,
totalInventory: inventory.length,
receivedToday: inventory.filter((item) => {
const arrivedAt = item.arrivedAt ?? item.createdAt;
return (
arrivedAt.getFullYear() === today.getFullYear() &&
arrivedAt.getMonth() === today.getMonth() &&
arrivedAt.getDate() === today.getDate()
);
}).length,
stored: byStatus.STORED ?? 0,
reserved: byStatus.RESERVED ?? 0,
readyForLoading: byStatus.READY_FOR_LOADING ?? 0,
loaded: byStatus.LOADED ?? 0,
dispatched: byStatus.DISPATCHED ?? 0,
};
}
// ── Inquiry ────────────────────────────────────────────────────────────
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
@@ -340,6 +450,45 @@ export class WarehouseInventoryService {
}
}
private async assertPaidBooking(manager: EntityManager, bookingId: string): Promise<void> {
const rows = await manager.query(
`SELECT id FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL AND status = 'PAID' AND payment_status = 'PAID'
LIMIT 1`,
[bookingId],
);
if (!rows || rows.length === 0) {
throw new BadRequestException('Only PAID bookings can reserve stored inventory.');
}
}
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
const bookingIds = Array.from(new Set(items.map((item) => item.bookingId).filter(Boolean)));
if (!bookingIds.length) return;
const rows = await this.dataSource.manager.query(
`SELECT id, reference, status, payment_status
FROM freight.bookings
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[bookingIds],
);
const byId = new Map<string, { id: string; reference: string; status: string; paymentStatus: string }>(
rows.map((row: { id: string; reference: string; status: string; payment_status: string }) => [
row.id,
{
id: row.id,
reference: row.reference,
status: row.status,
paymentStatus: row.payment_status,
},
]),
);
for (const item of items) {
Object.assign(item, { booking: byId.get(item.bookingId) ?? null });
}
}
private assertCapacity(
label: string,
node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number },

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
import { WarehouseZone } from './entities/warehouse-zone.entity';
@@ -19,7 +20,7 @@ import { WarehousesRepository } from './warehouses.repository';
import { WarehousesService } from './warehouses.service';
@Module({
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])],
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory, Yard])],
controllers: [
WarehousesController,
WarehouseYardsController,

View File

@@ -29,13 +29,14 @@ export class WarehousesService {
return this.warehousesRepository.findAll({
where: whereClauses,
relations: { facility: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Warehouse> {
const warehouse = await this.warehousesRepository.findById(id, {
relations: { yards: { zones: true } },
relations: { facility: true, yards: { zones: true } },
});
if (!warehouse) {

View File

@@ -42,8 +42,9 @@ import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedul
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
@@ -122,13 +123,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
title: "Warehouse Management",
items: [
{
label: "Warehouse dashboard",
href: "/dashboard/warehouses",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses/list",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
@@ -296,8 +302,10 @@ const App = () => {
<Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<FleetResourcePage />} />
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-dashboard" element={<Navigate to="/dashboard/warehouses" replace />} />
<Route path="warehouses" element={<WarehouseDashboardPage />} />
<Route path="warehouses/list" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />

View File

@@ -64,6 +64,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage route definitions built from freight yards",
},
},
{
prefix: "/dashboard/warehouses/list",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards, and zones",
},
},
{
prefix: "/dashboard/warehouse-inventory",
meta: {
title: "Warehouse inventory",
subtitle: "Track received items through inspection and loading",
},
},
{
prefix: "/dashboard/inventory-inquiry",
meta: {
title: "Inventory inquiry",
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouse dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
...getFleetRouteMeta(),
{
prefix: "/dashboard/trains/",

View File

@@ -10,7 +10,7 @@ import {
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import { useCreateWarehouse, useUpdateWarehouse, useWarehouseFacilities } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -24,6 +24,7 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
stationId: string;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
@@ -34,6 +35,7 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
stationId: '',
locationName: '',
capacityWeight: '',
capacityContainers: '',
@@ -45,8 +47,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const facilitiesQuery = useWarehouseFacilities();
const [form, setForm] = useState<FormState>(emptyForm());
const facilityOptions = (facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
}));
useEffect(() => {
if (opened) {
setForm(
@@ -55,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
stationId: warehouse.stationId ?? '',
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
@@ -77,6 +86,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
stationId: form.stationId || undefined,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
@@ -135,6 +145,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
)}
</Group>
<Select
label="Facility / Port"
placeholder="Select facility"
clearable
searchable
data={facilityOptions}
value={form.stationId || null}
onChange={(value) => setForm((f) => ({ ...f, stationId: value ?? '' }))}
/>
<TextInput
label="Location name"
placeholder="Modjo, Oromia"

View File

@@ -39,6 +39,12 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<WarehouseTypeBadge type={warehouse.type} />
</Group>
<Text size="sm" c="dimmed">
Facility: {warehouse.facility
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
: '—'}
</Text>
{warehouse.locationName && (
<Group gap={6} c="dimmed">
<MapPin size={14} />

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowLeftRight, ClipboardCheck, PackageCheck } from 'lucide-react';
import { Badge, Button, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
import { ArrowLeftRight, ClipboardCheck, PackageCheck, Send, Truck, Warehouse } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -8,7 +8,11 @@ import { formatDate, formatNumber } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onStore?: (item: WarehouseInventoryItem) => void;
onReserve?: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
onLoad?: (item: WarehouseInventoryItem) => void;
onDispatch?: (item: WarehouseInventoryItem) => void;
onMove?: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
}
@@ -17,13 +21,20 @@ const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: '', color: 'gray' };
return { label: '-', color: 'gray' };
};
const isPaidBooking = (item: WarehouseInventoryItem) =>
item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID';
export function WarehouseInventoryTable({
items,
onInspect,
onStore,
onReserve,
onReadyForLoading,
onLoad,
onDispatch,
onMove,
busyId,
}: WarehouseInventoryTableProps) {
@@ -36,14 +47,12 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table.ScrollContainer minWidth={1280}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Location Path</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
@@ -57,18 +66,38 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const paid = isPaidBooking(item);
const facility =
item.warehouse?.facility?.label ??
item.warehouse?.facility?.name ??
item.warehouse?.facility?.code ??
'No facility';
const locationPath = [
facility,
item.warehouse?.code ?? '-',
item.yard?.code ?? '-',
item.zone?.code ?? '-',
].join(' -> ');
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>
{item.booking?.reference ?? `${item.bookingId.slice(0, 8)}...`}
</Text>
<Text size="xs" c={paid ? 'green' : 'dimmed'}>
{item.booking?.paymentStatus ?? item.booking?.status ?? 'Unknown payment'}
</Text>
</Stack>
</Tooltip>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{locationPath}
</Text>
</Table.Td>
<Table.Td>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
@@ -109,17 +138,61 @@ export function WarehouseInventoryTable({
>
Inspect
</Button>
<Button
size="compact-xs"
variant="light"
color="blue"
leftSection={<Warehouse size={14} />}
disabled={!onStore || !['RECEIVED', 'ARRIVED_AT_WAREHOUSE'].includes(item.status) || busy}
loading={busy}
onClick={() => onStore?.(item)}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<ClipboardCheck size={14} />}
disabled={!onReserve || item.status !== 'STORED' || busy}
loading={busy}
onClick={() => onReserve?.(item)}
>
Reserve
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<PackageCheck size={14} />}
disabled={item.status !== 'UNDER_INSPECTION' || busy}
disabled={item.status !== 'RESERVED' || busy}
loading={busy}
onClick={() => onReadyForLoading(item)}
>
Ready
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
disabled={!onLoad || item.status !== 'READY_FOR_LOADING' || !paid || busy}
loading={busy}
onClick={() => onLoad?.(item)}
>
Loaded
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Send size={14} />}
disabled={!onDispatch || item.status !== 'LOADED' || busy}
loading={busy}
onClick={() => onDispatch?.(item)}
>
Dispatch
</Button>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -27,6 +27,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility / Port</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
@@ -44,6 +45,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.facility
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
: '—'}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />
</Table.Td>

View File

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

View File

@@ -244,10 +244,15 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
RESERVE: (id: string) => `/warehouse-inventory/${id}/reserve`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
},
};

View File

@@ -6,6 +6,7 @@ import type {
InventoryInquiryFilter,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
@@ -15,10 +16,12 @@ import type {
export const warehouseKeys = {
all: ['warehouses'] as const,
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
facilities: () => ['warehouses', 'facilities'] as const,
detail: (id: string) => ['warehouses', 'detail', id] as const,
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const,
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
};
@@ -39,6 +42,13 @@ export function useWarehouse(id?: string) {
});
}
export function useWarehouseFacilities() {
return useQuery({
queryKey: warehouseKeys.facilities(),
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
});
}
export function useCreateWarehouse() {
const qc = useQueryClient();
return useMutation({
@@ -127,6 +137,13 @@ export function useWarehouseInventory(filter?: InventoryFilter) {
});
}
export function useWarehouseDashboardSummary(filter?: InventoryFilter) {
return useQuery({
queryKey: warehouseKeys.dashboardSummary(filter),
queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data),
});
}
export function useReceiveInventory() {
const qc = useQueryClient();
return useMutation({
@@ -134,6 +151,30 @@ export function useReceiveInventory() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useStoreInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.storeInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useReserveInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: ReserveInventoryPayload }) =>
warehouseService.reserveInventory(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
@@ -150,7 +191,32 @@ export function useMarkReadyForLoading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useLoadInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.loadInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useDispatchInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.dispatchInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}

View File

@@ -0,0 +1,230 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Card,
Container,
Group,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
ClipboardCheck,
Container as ContainerIcon,
Layers,
Package,
PackagePlus,
Send,
Truck,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
useWarehouseDashboardSummary,
useWarehouseFacilities,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter } from '@/types/warehouse';
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<InventoryFilter>({});
const facilitiesQuery = useWarehouseFacilities();
const warehousesQuery = useWarehouses(filter.facilityId ? { stationId: filter.facilityId } : undefined);
const summaryQuery = useWarehouseDashboardSummary(filter);
const counts = summaryQuery.data ?? {
totalWarehouses: 0,
totalInventory: 0,
receivedToday: 0,
stored: 0,
reserved: 0,
readyForLoading: 0,
loaded: 0,
dispatched: 0,
};
const facilityOptions = useMemo(
() =>
(facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
})),
[facilitiesQuery.data],
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehousesQuery.data],
);
const loading = summaryQuery.isLoading;
const hasError = summaryQuery.isError;
const cards: Array<{
label: string;
value: number;
icon: typeof WarehouseIcon;
color: string;
href: string;
disabled?: boolean;
}> = [
{
label: 'Total warehouses',
value: counts.totalWarehouses,
icon: WarehouseIcon,
color: 'indigo',
href: '/dashboard/warehouses/list',
},
{
label: 'Total inventory',
value: counts.totalInventory,
icon: Package,
color: 'gray',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Received today',
value: counts.receivedToday,
icon: PackagePlus,
color: 'orange',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Stored',
value: counts.stored,
icon: Layers,
color: 'blue',
href: '/dashboard/warehouse-inventory?status=STORED',
},
{
label: 'Reserved',
value: counts.reserved,
icon: ClipboardCheck,
color: 'grape',
href: '/dashboard/warehouse-inventory?status=RESERVED',
},
{
label: 'Ready for loading',
value: counts.readyForLoading,
icon: ContainerIcon,
color: 'cyan',
href: '/dashboard/warehouse-inventory?status=READY_FOR_LOADING',
},
{
label: 'Loaded',
value: counts.loaded,
icon: Truck,
color: 'teal',
href: '/dashboard/warehouse-inventory?status=LOADED',
},
{
label: 'Dispatched',
value: counts.dispatched,
icon: Send,
color: 'green',
href: '/dashboard/warehouse-inventory?status=DISPATCHED',
},
];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<Stack gap="xl" mt="sm">
<Card withBorder radius="md" padding="xl" bg="gray.0">
<Group justify="space-between" align="center" gap="xl">
<div>
<Title order={1}>Warehouse Dashboard</Title>
<Text c="dimmed" size="lg" mt={6}>
Live overview of warehouse capacity and inventory lifecycle.
</Text>
</div>
<Group gap="xs" visibleFrom="sm">
<WarehouseIcon size={58} color="var(--mantine-color-green-6)" />
<Truck size={58} color="var(--mantine-color-orange-6)" />
</Group>
</Group>
</Card>
{hasError && (
<Alert color="yellow" variant="light" title="Some dashboard metrics could not be loaded">
Available cards still show data from the APIs that responded.
</Alert>
)}
<Card withBorder radius="md" padding="lg">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All facilities"
clearable
searchable
data={facilityOptions}
value={filter.facilityId ?? null}
onChange={(value) =>
setFilter((current) => ({ ...current, facilityId: value ?? undefined, warehouseId: undefined }))
}
w={260}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) => setFilter((current) => ({ ...current, warehouseId: value ?? undefined }))}
w={260}
/>
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card
key={card.label}
withBorder
radius="md"
padding="xl"
component="button"
type="button"
disabled={card.disabled}
onClick={() => navigate(card.href)}
style={{
cursor: card.disabled ? 'not-allowed' : 'pointer',
opacity: card.disabled ? 0.65 : 1,
textAlign: 'left',
transition: 'transform 120ms ease, box-shadow 120ms ease',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={10}>
<Text tt="uppercase" fw={700} c="dimmed" size="sm">
{card.label}
</Text>
{loading ? (
<Skeleton height={38} width={76} radius="sm" />
) : (
<Text fw={800} size="36px" lh={1}>
{card.value.toLocaleString()}
</Text>
)}
</Stack>
<ThemeIcon color={card.color} variant="light" size={52} radius="md">
<Icon size={28} />
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
</Stack>
</Container>
);
}

View File

@@ -118,7 +118,7 @@ export default function WarehouseDetailPage() {
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Warehouse not found</Text>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses/list')}>
Back to warehouses
</Button>
</Stack>
@@ -130,7 +130,8 @@ export default function WarehouseDetailPage() {
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
{ label: 'Warehouses', href: '/dashboard/warehouses' },
{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' },
{ label: 'Warehouses', href: '/dashboard/warehouses/list' },
{ label: warehouse.name },
]}
/>
@@ -138,7 +139,7 @@ export default function WarehouseDetailPage() {
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-start">
<Group gap="md" align="center">
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses/list')}>
<ArrowLeft size={18} />
</ActionIcon>
<div>

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
@@ -9,6 +10,7 @@ import {
Modal,
Select,
Stack,
Tabs,
Text,
Textarea,
TextInput,
@@ -26,9 +28,13 @@ import {
} from '@/components/warehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import {
useDispatchInventory,
useInspectInventory,
useLoadInventory,
useMarkReadyForLoading,
useMoveInventory,
useReserveInventory,
useStoreInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
@@ -38,8 +44,15 @@ import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@
export default function WarehouseInventoryPage() {
const { toast } = useToast();
const [filter, setFilter] = useState<InventoryFilter>({});
const [search, setSearch] = useState('');
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status') as InventoryStatus | null;
const [filter, setFilter] = useState<InventoryFilter>({
status: initialStatus ?? undefined,
warehouseId: searchParams.get('warehouseId') ?? undefined,
yardId: searchParams.get('yardId') ?? undefined,
zoneId: searchParams.get('zoneId') ?? undefined,
});
const [search, setSearch] = useState(searchParams.get('search') ?? '');
const [modalOpen, setModalOpen] = useState(false);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [moveDraft, setMoveDraft] = useState<{
@@ -64,7 +77,11 @@ export default function WarehouseInventoryPage() {
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const storeMutation = useStoreInventory();
const reserveMutation = useReserveInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const moveMutation = useMoveInventory();
const warehouseOptions = useMemo(
@@ -127,6 +144,82 @@ export default function WarehouseInventoryPage() {
}
};
const handleStore = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await storeMutation.mutateAsync(item.id);
toast({ title: 'Inventory stored' });
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReserve = async (item: WarehouseInventoryItem) => {
if (item.status !== 'STORED') {
toast({ variant: 'destructive', title: 'Only STORED inventory can be reserved.' });
return;
}
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can reserve stored inventory.' });
return;
}
setBusyId(item.id);
try {
await reserveMutation.mutateAsync({ id: item.id, payload: { bookingId: item.bookingId } });
toast({ title: 'Inventory reserved' });
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleLoad = async (item: WarehouseInventoryItem) => {
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can be loaded.' });
return;
}
setBusyId(item.id);
try {
await loadMutation.mutateAsync(item.id);
toast({ title: 'Inventory loaded' });
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleDispatch = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await dispatchMutation.mutateAsync(item.id);
toast({ title: 'Inventory dispatched' });
} catch (error) {
toast({ variant: 'destructive', title: 'Dispatch failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const inventory = inventoryQuery.data ?? [];
const readyToLoad = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
(item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID'),
);
const pendingPayment = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
item.booking?.status !== 'PAID' &&
item.booking?.paymentStatus !== 'PAID',
);
const loadedInventory = inventory.filter((item) => item.status === 'LOADED');
const handleMove = async () => {
if (!moveItem || !moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId) return;
setBusyId(moveItem.id);
@@ -223,15 +316,72 @@ export default function WarehouseInventoryPage() {
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
items={inventory}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
)}
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab value="ready">Ready to Load</Tabs.Tab>
<Tabs.Tab value="pending">Pending Payment</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded Inventory</Tabs.Tab>
<Tabs.Tab value="dispatch">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="ready" pt="md">
<WarehouseInventoryTable
items={readyToLoad}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="pending" pt="md">
<WarehouseInventoryTable
items={pendingPayment}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="loaded" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="dispatch" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
busyId={busyId}
/>
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />

View File

@@ -43,7 +43,7 @@ export default function WarehouseListPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouses' }]} />
<Breadcrumbs items={[{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' }, { label: 'Warehouses' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">

View File

@@ -7,10 +7,13 @@ import type {
InventoryInquiryResult,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseDashboardSummary,
WarehouseFacility,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseYard,
@@ -33,6 +36,7 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
// ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) =>
@@ -59,10 +63,22 @@ export const warehouseService = {
}),
receiveInventory: (payload: ReceiveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, payload),
getDashboardSummary: (filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboardSummary>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DASHBOARD_SUMMARY, {
params: cleanParams(filter ?? {}),
}),
storeInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
reserveInventory: (id: string, payload: ReserveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE(id), payload),
inspectInventory: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECT(id)),
markReadyForLoading: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
loadInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id)),
dispatchInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
moveInventory: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
listReadyForLoading: (filter?: InventoryFilter) =>

View File

@@ -23,12 +23,25 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'RECEIVED',
'STORED',
'RESERVED',
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export interface WarehouseFacility {
id: string;
name?: string;
label?: string;
code: string;
type?: string;
}
export interface WarehouseZone {
id: string;
yardId: string;
@@ -64,6 +77,7 @@ export interface Warehouse {
code: string;
type: WarehouseType;
stationId: string | null;
facility?: WarehouseFacility | null;
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
@@ -93,6 +107,12 @@ export interface WarehouseInventoryItem {
inspectedAt: string | null;
readyForLoadingAt: string | null;
notes: string | null;
booking?: {
id: string;
reference: string;
status: string;
paymentStatus: string;
} | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
@@ -124,6 +144,7 @@ export interface SaveWarehousePayload {
code: string;
type: WarehouseType;
stationId?: string;
facilityId?: string;
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
@@ -169,6 +190,21 @@ export interface MoveInventoryPayload {
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
}
export interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
export interface WarehouseFilter {
search?: string;
type?: WarehouseType;
@@ -177,6 +213,7 @@ export interface WarehouseFilter {
}
export interface InventoryFilter {
facilityId?: string;
warehouseId?: string;
yardId?: string;
zoneId?: string;
@@ -186,6 +223,8 @@ export interface InventoryFilter {
goodsId?: string;
status?: InventoryStatus;
search?: string;
dateFrom?: string;
dateTo?: string;
}
export interface InventoryInquiryFilter {