mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
warehouse
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user