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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
|
||||
16
apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts
Normal file
16
apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
16
apps/edr-freight-web/backoffice/src/hooks/useStations.ts
Normal file
16
apps/edr-freight-web/backoffice/src/hooks/useStations.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user