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'; 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({ const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' }, 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 { 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() @IsOptional()
@IsString() @IsString()
deliveryRemarks?: string; deliveryRemarks?: string;

View File

@@ -38,6 +38,16 @@ export class Cargo extends BaseEntity {
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null; 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 // Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'container_id' }) @JoinColumn({ name: 'container_id' })

View File

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

View File

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

View File

@@ -29,13 +29,14 @@ export class WarehousesService {
return this.warehousesRepository.findAll({ return this.warehousesRepository.findAll({
where: whereClauses, where: whereClauses,
relations: { facility: true },
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
} }
async findById(id: string): Promise<Warehouse> { async findById(id: string): Promise<Warehouse> {
const warehouse = await this.warehousesRepository.findById(id, { const warehouse = await this.warehousesRepository.findById(id, {
relations: { yards: { zones: true } }, relations: { facility: true, yards: { zones: true } },
}); });
if (!warehouse) { if (!warehouse) {
@@ -53,6 +54,7 @@ export class WarehousesService {
code: dto.code.trim(), code: dto.code.trim(),
type: dto.type, type: dto.type,
stationId: dto.stationId ?? null, stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
locationName: dto.locationName?.trim() ?? null, locationName: dto.locationName?.trim() ?? null,
capacityWeight: dto.capacityWeight ?? null, capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null, capacityContainers: dto.capacityContainers ?? null,
@@ -80,6 +82,7 @@ export class WarehousesService {
code: dto.code?.trim() ?? existing.code, code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type, type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId, stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
locationName: dto.locationName?.trim() ?? existing.locationName, locationName: dto.locationName?.trim() ?? existing.locationName,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers, capacityContainers: dto.capacityContainers ?? existing.capacityContainers,

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; 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 { Facility } from '../modules/facilities/entities/facility.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
@@ -148,12 +147,11 @@ export class Batch14TestDataSeeder {
warehouseId: warehouse.id, warehouseId: warehouse.id,
yardId: zone.yardId, yardId: zone.yardId,
zoneId: zone.id, zoneId: zone.id,
code: `TEST_INV_${status}_001`,
status: status as any, status: status as any,
quantity: 100 + i * 10, quantity: 100 + i * 10,
weight: 500 + i * 50, weight: 500 + i * 50,
volume: 100 + i * 10, 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, storedAt: status !== 'RECEIVED' ? new Date(now.getTime() - (i - 1) * 3600000) : null,
reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,
readyForLoadingAt: ['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'; } from '@mantine/core';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useStations } from '@/hooks/useStations';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses'; import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -24,6 +25,7 @@ interface FormState {
name: string; name: string;
code: string; code: string;
type: WarehouseType; type: WarehouseType;
stationId: string | null;
locationName: string; locationName: string;
capacityWeight: number | ''; capacityWeight: number | '';
capacityContainers: number | ''; capacityContainers: number | '';
@@ -35,6 +37,7 @@ const emptyForm = (): FormState => ({
name: '', name: '',
code: '', code: '',
type: 'OPEN_WAREHOUSE', type: 'OPEN_WAREHOUSE',
stationId: null,
locationName: '', locationName: '',
capacityWeight: '', capacityWeight: '',
capacityContainers: '', capacityContainers: '',
@@ -47,8 +50,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
const { toast } = useToast(); const { toast } = useToast();
const createMutation = useCreateWarehouse(); const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse(); const updateMutation = useUpdateWarehouse();
const { data: stations } = useStations();
const [form, setForm] = useState<FormState>(emptyForm()); const [form, setForm] = useState<FormState>(emptyForm());
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
useEffect(() => { useEffect(() => {
if (opened) { if (opened) {
setForm( setForm(
@@ -57,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name, name: warehouse.name,
code: warehouse.code, code: warehouse.code,
type: warehouse.type, type: warehouse.type,
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '', locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '', capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '', capacityContainers: warehouse.capacityContainers ?? '',
@@ -80,6 +87,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(), name: form.name.trim(),
code: form.code.trim(), code: form.code.trim(),
type: form.type, type: form.type,
stationId: form.stationId ?? undefined,
locationName: form.locationName.trim() || undefined, locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight), capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers), capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
@@ -120,6 +128,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
/> />
</Group> </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> <Group grow>
<Select <Select
label="Type" label="Type"

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; 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 type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options'; import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseCardViewProps {
} }
export function WarehouseCardView({ warehouses, onView, onEdit }: 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) { if (warehouses.length === 0) {
return ( return (
<Text c="dimmed" ta="center" py="xl"> <Text c="dimmed" ta="center" py="xl">
@@ -39,6 +47,13 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<WarehouseTypeBadge type={warehouse.type} /> <WarehouseTypeBadge type={warehouse.type} />
</Group> </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 && ( {warehouse.locationName && (
<Group gap={6} c="dimmed"> <Group gap={6} c="dimmed">
<MapPin size={14} /> <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.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Booking</Table.Th> <Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th> <Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th> <Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th> <Table.Th>Zone</Table.Th>
@@ -81,6 +82,7 @@ export function WarehouseInventoryTable({
</Text> </Text>
)} )}
</Table.Td> </Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td> <Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td> <Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.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 { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react'; import { Eye, Pencil } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse'; import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options'; import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseTableProps {
} }
export function WarehouseTable({ warehouses, onView, onEdit }: 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) { if (warehouses.length === 0) {
return ( return (
<Text c="dimmed" ta="center" py="xl"> <Text c="dimmed" ta="center" py="xl">
@@ -27,6 +35,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<Table.Tr> <Table.Tr>
<Table.Th>Code</Table.Th> <Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th> <Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th> <Table.Th>Weight (cur / cap)</Table.Th>
@@ -44,6 +53,17 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</Anchor> </Anchor>
</Table.Td> </Table.Td>
<Table.Td>{warehouse.name}</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> <Table.Td>
<WarehouseTypeBadge type={warehouse.type} /> <WarehouseTypeBadge type={warehouse.type} />
</Table.Td> </Table.Td>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cargoService } from '@/services/cargoService'; import { cargoService, type DeliverCargoPayload } from '@/services/cargoService';
export const cargoKeys = { export const cargoKeys = {
all: ['cargoes'] as const, all: ['cargoes'] as const,
@@ -53,7 +53,8 @@ export function useLoadCargo() {
export function useDeliverCargo() { export function useDeliverCargo() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ 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 }) 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, useUpdateLocomotive,
} from '@/hooks/useLocomotives'; } from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService'; import type { Cargo } from '@/services/cargoService';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import type { Container } from '@/services/containerService'; import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service'; import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service'; import type { Train } from '@/services/trains.service';
@@ -104,6 +105,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
removeConfirmMessage?: string; removeConfirmMessage?: string;
removeSuccessMessage?: string; removeSuccessMessage?: string;
hideViewAction?: boolean; 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>) => const normalizePayload = (values: Record<string, FormValue>) =>
@@ -185,6 +188,7 @@ function FleetCrudPage<T extends { id: string }>({
removeConfirmMessage, removeConfirmMessage,
removeSuccessMessage, removeSuccessMessage,
hideViewAction = false, hideViewAction = false,
rowActions,
}: FleetCrudPageProps<T>) { }: FleetCrudPageProps<T>) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -353,6 +357,7 @@ function FleetCrudPage<T extends { id: string }>({
))} ))}
<TableCell> <TableCell>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
{rowActions?.(item)}
{!hideViewAction ? ( {!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View"> <Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" /> <Eye className="size-4" />
@@ -1065,7 +1070,18 @@ export function CargoesCrudPage() {
{ key: 'quantity', label: 'Quantity' }, { key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' }, { key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) }, { 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={[ fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true }, { key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', 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 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 { 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: * Tabs:
* - Awaiting Payment: UNPAID bookings * - Ready to Load: READY_FOR_LOADING + booking PAID (can Mark as Loaded)
* - Ready For Loading: PAID bookings ready to load onto wagon * - 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() { export default function LoadingQueuePage() {
const { data: paidItems, isLoading: paidLoading } = useWarehouseInventory({ status: 'READY_FOR_LOADING' }); const navigate = useNavigate();
const paidBookings = paidItems ?? []; 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 ( return (
<Container size="xxl" py="lg"> <Container size="xxl" py="lg">
@@ -29,31 +51,97 @@ export default function LoadingQueuePage() {
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready"> <Tabs defaultValue="ready">
<Tabs.List> <Tabs.List>
<Tabs.Tab value="awaiting" leftSection={<Badge size="xs">Unpaid</Badge>}> <Tabs.Tab
Awaiting Payment value="ready"
leftSection={
<Badge size="xs" color="green">
{paidItems.length}
</Badge>
}
>
Ready to Load
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="ready" leftSection={<Badge size="xs" color="green">Ready</Badge>}> <Tabs.Tab
Ready For Loading 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.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="awaiting" pt="md"> {/* Ready to Load — PAID bookings, can be marked Loaded */}
<VisualEmptyState
variant="wagon"
title="No unpaid bookings"
description="Unpaid bookings will appear here pending payment verification."
/>
</Tabs.Panel>
<Tabs.Panel value="ready" pt="md"> <Tabs.Panel value="ready" pt="md">
{!paidLoading && paidBookings.length === 0 ? ( {!readyLoading && paidItems.length === 0 ? (
<VisualEmptyState <VisualEmptyState
variant="wagon" variant="wagon"
title="Nothing waiting to load" title="Nothing ready to load"
description="Items appear here once they are marked Ready For Loading." 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.Panel>
</Tabs> </Tabs>
@@ -62,3 +150,71 @@ export default function LoadingQueuePage() {
</Container> </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 { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { import {
ClipboardCheck, ClipboardCheck,
@@ -11,29 +12,36 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs'; import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses'; import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse'; 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 { interface Metric {
key: keyof WarehouseDashboard; key: keyof WarehouseDashboard;
label: string; label: string;
color: string;
icon: React.ReactNode; icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
} }
const METRICS: Metric[] = [ const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', color: 'indigo', icon: <WarehouseIcon size={20} /> }, { key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', color: 'gray', icon: <Boxes size={20} /> }, { key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', color: 'yellow', icon: <PackagePlus size={20} /> }, { key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'stored', label: 'Stored', color: 'blue', icon: <Layers size={20} /> }, { key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', color: 'grape', icon: <ClipboardCheck size={20} /> }, { key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', color: 'cyan', icon: <PackageCheck size={20} /> }, { key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', color: 'teal', icon: <Truck size={20} /> }, { key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', color: 'green', icon: <Send size={20} /> }, { key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
]; ];
export default function WarehouseDashboardPage() { export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isLoading } = useWarehouseDashboard(); const { data, isLoading } = useWarehouseDashboard();
return ( return (
@@ -53,25 +61,53 @@ export default function WarehouseDashboardPage() {
<Loader /> <Loader />
</Center> </Center>
) : ( ) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md"> <SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => ( {METRICS.map((metric) => (
<Card key={metric.key} withBorder radius="md" padding="lg"> <Card
<Group justify="space-between" align="flex-start"> 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> <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} {metric.label}
</Text> </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} {data ? data[metric.key] : 0}
</Text> </Text>
</div> </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} {metric.icon}
</ThemeIcon> </ThemeIcon>
</Group> </Group>
</Card> </Card>
))} ))}
</SimpleGrid> </SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)} )}
</Stack> </Stack>
</Container> </Container>

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; 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 { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react'; import { PackagePlus, Search } from 'lucide-react';
@@ -18,7 +19,11 @@ import {
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() { 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 [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);

View File

@@ -15,6 +15,15 @@ export interface Cargo {
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED'; status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED';
loadedAt?: string; loadedAt?: string;
unloadedAt?: string; unloadedAt?: string;
receiverName?: string | null;
deliveredAt?: string | null;
deliveryRemarks?: string | null;
}
export interface DeliverCargoPayload {
receiverName?: string;
pickupDate?: string;
deliveryRemarks?: string;
} }
export const cargoService = { export const cargoService = {
@@ -26,6 +35,7 @@ export const cargoService = {
delete: (id: string) => apiClient.delete(`/cargoes/${id}`), delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
load: (cargoId: string, quantity: number, weight: number, volume?: number) => load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }), 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`), 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[]; 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 { export interface Warehouse {
id: string; id: string;
name: string; name: string;
code: string; code: string;
type: WarehouseType; type: WarehouseType;
stationId: string | null; stationId: string | null;
facilityId: string | null;
facility?: Facility | null;
locationName: string | null; locationName: string | null;
capacityWeight: number | null; capacityWeight: number | null;
capacityContainers: number | null; capacityContainers: number | null;
@@ -124,6 +147,15 @@ export interface WarehouseInventoryItem {
warehouse?: Warehouse | null; warehouse?: Warehouse | null;
yard?: WarehouseYard | null; yard?: WarehouseYard | null;
zone?: WarehouseZone | 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 { export interface InventoryMovement {
@@ -259,6 +291,7 @@ export interface SaveWarehousePayload {
code: string; code: string;
type: WarehouseType; type: WarehouseType;
stationId?: string; stationId?: string;
facilityId?: string;
locationName?: string; locationName?: string;
capacityWeight?: number; capacityWeight?: number;
capacityContainers?: number; capacityContainers?: number;