Warehouse

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

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Proof of Delivery (customer pickup) capture on cargoes:
* receiver name, delivered/picked-up timestamp, and delivery remarks.
*/
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('freight.cargoes', [
new TableColumn({ name: 'receiver_name', type: 'varchar', isNullable: true }),
new TableColumn({ name: 'delivered_at', type: 'timestamp', isNullable: true }),
new TableColumn({ name: 'delivery_remarks', type: 'text', isNullable: true }),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumns('freight.cargoes', ['receiver_name', 'delivered_at', 'delivery_remarks']);
}
}

View File

@@ -157,7 +157,9 @@ export class CargoesService {
}
cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date();
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },

View File

@@ -1,6 +1,16 @@
import { IsOptional, IsString } from 'class-validator';
import { IsDateString, IsOptional, IsString } from 'class-validator';
export class DeliverCargoDto {
/** Name of the person who received / picked up the cargo (Proof of Delivery). */
@IsOptional()
@IsString()
receiverName?: string;
/** When the cargo was picked up / delivered. Defaults to now. */
@IsOptional()
@IsDateString()
pickupDate?: string;
@IsOptional()
@IsString()
deliveryRemarks?: string;

View File

@@ -38,6 +38,16 @@ export class Cargo extends BaseEntity {
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null;
// Proof of Delivery (customer pickup) capture.
@Column({ name: 'receiver_name', type: 'varchar', nullable: true })
receiverName!: string | null;
@Column({ name: 'delivered_at', type: 'timestamp', nullable: true })
deliveredAt!: Date | null;
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
deliveryRemarks!: string | null;
// Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'container_id' })

View File

@@ -23,6 +23,11 @@ export class CreateWarehouseDto {
@IsUUID()
stationId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -86,7 +86,7 @@ export class WarehouseInventoryService {
return this.inventoryRepository.findAll({
where,
relations: { warehouse: true, yard: true, zone: true },
relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true },
order: { createdAt: 'DESC' },
});
}

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) {
@@ -53,6 +54,7 @@ export class WarehousesService {
code: dto.code.trim(),
type: dto.type,
stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
locationName: dto.locationName?.trim() ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
@@ -80,6 +82,7 @@ export class WarehousesService {
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
locationName: dto.locationName?.trim() ?? existing.locationName,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Facility } from '../modules/facilities/entities/facility.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
@@ -148,12 +147,11 @@ export class Batch14TestDataSeeder {
warehouseId: warehouse.id,
yardId: zone.yardId,
zoneId: zone.id,
code: `TEST_INV_${status}_001`,
status: status as any,
quantity: 100 + i * 10,
weight: 500 + i * 50,
volume: 100 + i * 10,
arrivedAt: new Date(now.getTime() - i * 3600000), // Staggered arrival times
arrivedAt: new Date(now.getTime() - i * 3600000),
storedAt: status !== 'RECEIVED' ? new Date(now.getTime() - (i - 1) * 3600000) : null,
reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,
readyForLoadingAt: ['READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { PackageCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDeliverCargo } from '@/hooks/useCargoes';
import { useToast } from '@/hooks/use-toast';
/**
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
* Records receiver name, pickup date and remarks, then marks the cargo DELIVERED.
*/
export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
const [open, setOpen] = useState(false);
const [receiverName, setReceiverName] = useState('');
const [pickupDate, setPickupDate] = useState('');
const [deliveryRemarks, setDeliveryRemarks] = useState('');
const deliver = useDeliverCargo();
const { toast } = useToast();
const handleDeliver = async () => {
if (!receiverName.trim()) {
toast({ title: 'Receiver name is required', variant: 'destructive' });
return;
}
try {
await deliver.mutateAsync({
id: cargoId,
payload: {
receiverName: receiverName.trim(),
pickupDate: pickupDate ? new Date(pickupDate).toISOString() : undefined,
deliveryRemarks: deliveryRemarks.trim() || undefined,
},
});
toast({ title: 'Delivered', description: 'Proof of delivery recorded; cargo marked delivered.' });
setOpen(false);
setReceiverName('');
setPickupDate('');
setDeliveryRemarks('');
onSuccess?.();
} catch (error) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'Could not record delivery.';
toast({ title: 'Delivery failed', description: String(message), variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Customer pickup / Proof of delivery">
<PackageCheck className="size-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Customer Pickup &amp; Proof of Delivery</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Receiver name*</Label>
<Input
required
value={receiverName}
placeholder="Name of person collecting the cargo"
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
<div>
<Label>Pickup date</Label>
<Input
type="datetime-local"
value={pickupDate}
onChange={(e) => setPickupDate(e.target.value)}
/>
</div>
<div>
<Label>Remarks</Label>
<Textarea
value={deliveryRemarks}
placeholder="Condition on handover, ID checked, etc."
onChange={(e) => setDeliveryRemarks(e.target.value)}
/>
</div>
<Button onClick={handleDeliver} disabled={deliver.isPending} className="w-full">
{deliver.isPending ? 'Recording…' : 'Confirm Pickup & Mark Delivered'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,6 +10,7 @@ import {
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useStations } from '@/hooks/useStations';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -24,6 +25,7 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
@@ -35,6 +37,7 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
stationId: null,
locationName: '',
capacityWeight: '',
capacityContainers: '',
@@ -47,8 +50,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const { data: stations } = useStations();
const [form, setForm] = useState<FormState>(emptyForm());
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
useEffect(() => {
if (opened) {
setForm(
@@ -57,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
@@ -80,6 +87,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),
@@ -120,6 +128,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
/>
</Group>
<Select
label="Facility / Port (Station)"
placeholder="Select parent station"
data={stationOptions}
value={form.stationId}
onChange={(value) => setForm((f) => ({ ...f, stationId: value }))}
searchable
clearable
/>
<Group grow>
<Select
label="Type"

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
import { Eye, MapPin, Pencil } from 'lucide-react';
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseCardViewProps {
}
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
const { data: stations } = useStations();
const stationNameById = useMemo(
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -39,6 +47,13 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<WarehouseTypeBadge type={warehouse.type} />
</Group>
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
<Group gap={6} c="dimmed">
<Building2 size={14} />
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
</Group>
)}
{warehouse.locationName && (
<Group gap={6} c="dimmed">
<MapPin size={14} />

View File

@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react';
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
interface WarehouseDashboardChartsProps {
data?: WarehouseDashboard;
}
const ORANGE = '#f08c00';
const GREEN = '#5bbf4a';
/** Inventory lifecycle status series — alternating orange / light green. */
const STATUS_SERIES = [
{ key: 'stored', label: 'Stored', color: ORANGE },
{ key: 'reserved', label: 'Reserved', color: GREEN },
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
{ key: 'loaded', label: 'Loaded', color: GREEN },
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
] as const;
type Granularity = 'week' | 'month' | 'year';
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState<Granularity>('month');
const { data: inventory } = useWarehouseInventory();
const statusData = STATUS_SERIES.map((s) => ({
name: s.label,
value: data ? Number(data[s.key as keyof WarehouseDashboard] ?? 0) : 0,
color: s.color,
}));
const hasStatus = statusData.some((d) => d.value > 0);
const trend = useMemo(
() => buildTrend(inventory ?? [], granularity),
[inventory, granularity],
);
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Time-filtered throughput */}
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
<Group justify="space-between" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
<CalendarRange size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Throughput Over Time</Text>
<Text size="xs" c="dimmed">
Received vs dispatched inventory
</Text>
</div>
</Group>
<SegmentedControl
value={granularity}
onChange={(v) => setGranularity(v as Granularity)}
data={[
{ label: 'Weekly', value: 'week' },
{ label: 'Monthly', value: 'month' },
{ label: 'Yearly', value: 'year' },
]}
/>
</Group>
{hasTrend ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={trend} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Legend iconType="circle" />
<Bar dataKey="received" name="Received" fill={ORANGE} radius={[6, 6, 0, 0]} />
<Bar dataKey="dispatched" name="Dispatched" fill={GREEN} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
{/* Status distribution bar */}
<Card withBorder radius="lg" padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: GREEN, color: '#fff' }}>
<BarChart3 size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Inventory by Status</Text>
<Text size="xs" c="dimmed">
Items at each lifecycle stage
</Text>
</div>
</Group>
{hasStatus ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={statusData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Bar dataKey="value" name="Items" radius={[6, 6, 0, 0]}>
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
{/* Status distribution donut */}
<Card withBorder radius="lg" padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
<PieChartIcon size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Lifecycle Distribution</Text>
<Text size="xs" c="dimmed">
Share of inventory across statuses
</Text>
</div>
</Group>
{hasStatus ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={statusData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry, i) => (
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
))}
</Pie>
<Tooltip />
<Legend verticalAlign="bottom" height={36} iconType="circle" />
</PieChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
</SimpleGrid>
);
}
interface TrendBucket {
label: string;
received: number;
dispatched: number;
}
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
const now = new Date();
const buckets: { label: string; start: Date; end: Date }[] = [];
if (granularity === 'week') {
for (let i = 7; i >= 0; i--) {
const end = new Date(now);
end.setDate(now.getDate() - i * 7);
const start = new Date(end);
start.setDate(end.getDate() - 7);
buckets.push({ label: `W${8 - i}`, start, end });
}
} else if (granularity === 'month') {
for (let i = 11; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
buckets.push({
label: start.toLocaleString('en', { month: 'short' }),
start,
end,
});
}
} else {
for (let i = 4; i >= 0; i--) {
const year = now.getFullYear() - i;
buckets.push({
label: String(year),
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
});
}
}
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
if (!iso) return false;
const t = new Date(iso).getTime();
return t >= start.getTime() && t < end.getTime();
};
return buckets.map((b) => ({
label: b.label,
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
}));
}
function EmptyChart() {
return (
<Group justify="center" align="center" h={280}>
<Text c="dimmed" size="sm">
No inventory data to chart yet.
</Text>
</Group>
);
}

View File

@@ -50,6 +50,7 @@ export function WarehouseInventoryTable({
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
@@ -81,6 +82,7 @@ export function WarehouseInventoryTable({
</Text>
)}
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseTableProps {
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
const { data: stations } = useStations();
const stationNameById = useMemo(
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -27,6 +35,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
@@ -44,6 +53,17 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
<Text size="sm" fw={500}>
{stationNameById.get(warehouse.stationId)}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />
</Table.Td>

View File

@@ -24,3 +24,4 @@ export { FreightVisual } from './FreightVisual';
export type { FreightVisualVariant } from './FreightVisual';
export { WarehouseHero } from './WarehouseHero';
export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';

View File

@@ -184,6 +184,11 @@ export const URL_CONSTANTS = {
CURRENCIES: '/api/reference/currencies',
},
FACILITIES: {
BASE: '/facilities',
BY_ID: (id: string) => `/facilities/${id}`,
},
WAREHOUSES: {
BASE: '/warehouses',
DASHBOARD: '/warehouses/dashboard',

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cargoService } from '@/services/cargoService';
import { cargoService, type DeliverCargoPayload } from '@/services/cargoService';
export const cargoKeys = {
all: ['cargoes'] as const,
@@ -53,7 +53,8 @@ export function useLoadCargo() {
export function useDeliverCargo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => cargoService.deliver(id),
mutationFn: ({ id, payload }: { id: string; payload?: DeliverCargoPayload }) =>
cargoService.deliver(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { facilityService } from '@/services/facility.service';
export const facilityKeys = {
all: ['facilities'] as const,
list: () => ['facilities', 'list'] as const,
detail: (id: string) => ['facilities', 'detail', id] as const,
};
export function useFacilities() {
return useQuery({
queryKey: facilityKeys.list(),
queryFn: () => facilityService.list().then((r) => r.data),
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
/**
* The 21 network stations / yards, sourced from the existing booking
* reference-data API. Reused as the parent "Facility / Port" for warehouses.
*/
export function useStations() {
return useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
staleTime: 5 * 60 * 1000,
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,15 @@ export interface Cargo {
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED';
loadedAt?: string;
unloadedAt?: string;
receiverName?: string | null;
deliveredAt?: string | null;
deliveryRemarks?: string | null;
}
export interface DeliverCargoPayload {
receiverName?: string;
pickupDate?: string;
deliveryRemarks?: string;
}
export const cargoService = {
@@ -26,6 +35,7 @@ export const cargoService = {
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
deliver: (cargoId: string, payload?: DeliverCargoPayload) =>
apiClient.post(`/cargoes/${cargoId}/deliver`, payload ?? {}),
unload: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/unload`),
};

View File

@@ -0,0 +1,9 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type { Facility } from '@/types/warehouse';
export const facilityService = {
list: () => apiClient.get<Facility[]>(URL_CONSTANTS.FACILITIES.BASE),
getById: (id: string) => apiClient.get<Facility>(URL_CONSTANTS.FACILITIES.BY_ID(id)),
};

View File

@@ -79,12 +79,35 @@ export interface WarehouseYard {
zones?: WarehouseZone[];
}
export const FACILITY_TYPES = [
'PORT',
'DRY_PORT',
'TERMINAL',
'RAIL_YARD',
'WAREHOUSE_COMPLEX',
] as const;
export type FacilityType = (typeof FACILITY_TYPES)[number];
export interface Facility {
id: string;
code: string;
name: string;
facilityType: FacilityType;
facilityStatus?: string;
locationName?: string | null;
city?: string | null;
country?: string | null;
isActive?: boolean;
}
export interface Warehouse {
id: string;
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
facilityId: string | null;
facility?: Facility | null;
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
@@ -124,6 +147,15 @@ export interface WarehouseInventoryItem {
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
booking?: InventoryBookingRef | null;
}
/** Slim booking shape returned alongside inventory for the loading queue. */
export interface InventoryBookingRef {
id: string;
reference?: string | null;
status?: string | null;
paymentStatus?: string | null;
}
export interface InventoryMovement {
@@ -259,6 +291,7 @@ export interface SaveWarehousePayload {
code: string;
type: WarehouseType;
stationId?: string;
facilityId?: string;
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;