This commit is contained in:
hagiye
2026-06-24 09:56:12 +03:00
255 changed files with 21010 additions and 7887 deletions

View File

@@ -226,7 +226,7 @@ const FreightSidebar = ({
return (
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>

View File

@@ -8,8 +8,10 @@ import {
useGenerateInvoice,
useInvoicesForInventory,
} from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
@@ -35,6 +37,9 @@ function fmtDate(iso: string | null) {
return new Date(iso).toLocaleDateString();
}
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
function FeeCard({ fee }: { fee: FeePreview }) {
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
const configured = Boolean(fee.ruleId);
@@ -51,7 +56,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
)}
</Group>
<Text fw={800} size="lg" c={`${meta.color}.7`}>
{fee.amount.toLocaleString()} {fee.currency}
{money(fee.amount, fee.currency)}
</Text>
</Group>
@@ -63,7 +68,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Stack gap={4}>
<Row label="Rule" value={fee.ruleName ?? '—'} />
<Row label="Free days" value={String(fee.freeDays)} />
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
<Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
@@ -99,7 +104,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
if (!inventoryId) return;
try {
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} ${inv.totalAmount} ${inv.currency}` });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
} catch (error) {
const msg = extractErrorMessage(error);
if (/no payable warehouse fee/i.test(msg)) {
@@ -114,11 +119,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
const handleGateClearance = async () => {
if (!inventoryId) return;
const pdfWindow = window.open('', '_blank');
try {
await gateClear.mutateAsync(inventoryId);
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
const response = await gateClear.mutateAsync(inventoryId) as { data?: { booking?: { reference?: string | null }; bookingId?: string | null } };
const releasedItem = response.data;
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
onClose();
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
}
};
@@ -158,7 +174,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
</Badge>
</Group>
<Text size="sm">
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
</Text>
</Group>
) : (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Button,
Divider,
@@ -13,7 +13,7 @@ import {
import { Upload } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {
INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES,
@@ -47,6 +47,7 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
const { toast } = useToast();
const createReport = useCreateInspectionReport();
const uploadAttachments = useUploadInspectionAttachments();
const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
@@ -76,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
setFiles([]);
};
useEffect(() => {
if (!opened) return;
const report = reportsQuery.data?.[0];
if (!report) {
reset();
return;
}
setReportType(report.reportType);
setInspectionStatus(report.inspectionStatus);
setHasDamage(report.hasDamage ?? false);
setDamageDescription(report.damageDescription ?? '');
setHasWeightLoss(report.hasWeightLoss ?? false);
setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight));
setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight));
setHasMissingItems(report.hasMissingItems ?? false);
setMissingItemsDescription(report.missingItemsDescription ?? '');
setRemarks(report.remarks ?? '');
setFiles([]);
}, [opened, reportsQuery.data]);
const handleSubmit = async () => {
if (!inventoryId) return;
try {

View File

@@ -0,0 +1,91 @@
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface InventoryDetailModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text size="sm" fw={500}>
{value || '-'}
</Text>
</Stack>
);
}
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? (
<Text c="dimmed">No inventory item selected.</Text>
) : (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {item.id}
</Text>
</Stack>
<InventoryStatusBadge status={item.status} />
</Group>
<Divider label="Location" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Warehouse" value={item.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : '-'} />
<DetailRow label="Yard" value={item.yard ? `${item.yard.name} (${item.yard.code})` : '-'} />
<DetailRow label="Zone" value={item.zone ? `${item.zone.name} (${item.zone.code})` : '-'} />
</SimpleGrid>
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
</SimpleGrid>
<Divider label="Lifecycle" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Inspection" value={<Badge variant="light">{item.inspectionStatus ?? 'Not inspected'}</Badge>} />
<DetailRow label="Arrived" value={formatDate(item.arrivedAt)} />
<DetailRow label="Stored" value={formatDate(item.storedAt)} />
<DetailRow label="Reserved" value={formatDate(item.reservedAt)} />
<DetailRow label="Inspected" value={formatDate(item.inspectedAt)} />
<DetailRow label="Ready for loading" value={formatDate(item.readyForLoadingAt)} />
<DetailRow label="Loaded" value={formatDate(item.loadedAt)} />
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid>
{item.notes && (
<>
<Divider label="Notes" labelPosition="left" />
<Text size="sm">{item.notes}</Text>
</>
)}
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,92 @@
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface InventoryInquiryDetailModalProps {
opened: boolean;
onClose: () => void;
result: InventoryInquiryResult | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text size="sm" fw={500}>
{value || '-'}
</Text>
</Stack>
);
}
function itemLabel(result: InventoryInquiryResult) {
if (result.containerNumber) return `Container ${result.containerNumber}`;
if (result.cargoType) return result.cargoType;
if (result.cargoDescription) return result.cargoDescription;
if (result.goodsId) return `Goods ${result.goodsId}`;
return '-';
}
export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory inquiry detail" centered size="xl">
{!result ? (
<Text c="dimmed">No inquiry result selected.</Text>
) : (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'}
</Text>
</Stack>
{result.status ? (
<InventoryStatusBadge status={result.status} />
) : (
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'}>
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
</Badge>
)}
</Group>
<Divider label="Booking" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={result.bookingReference ?? result.bookingNumber ?? '-'} />
<DetailRow label="Booking status" value={result.bookingStatus ?? '-'} />
<DetailRow label="Customer" value={result.customerName ?? '-'} />
</SimpleGrid>
<Divider label="Item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Item" value={itemLabel(result)} />
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
</SimpleGrid>
<Divider label="Location" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Warehouse" value={result.warehouse ? `${result.warehouse.name} (${result.warehouse.code})` : '-'} />
<DetailRow label="Yard" value={result.yard ? `${result.yard.name} (${result.yard.code})` : '-'} />
<DetailRow label="Zone" value={result.zone ? `${result.zone.name} (${result.zone.code})` : '-'} />
<DetailRow label="Current location" value={result.locationSummary ?? '-'} />
<DetailRow label="Train" value={result.trainNumber ?? '-'} />
<DetailRow label="Route" value={result.route ?? '-'} />
</SimpleGrid>
<Divider label="Dates" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Arrived" value={formatDate(result.arrivedAt)} />
<DetailRow label="Ready for loading" value={formatDate(result.readyForLoadingAt)} />
</SimpleGrid>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,17 +1,21 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
import { ClipboardCheck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
useStoreInventory,
} from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
@@ -19,20 +23,24 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
@@ -42,6 +50,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: { inspectedCount: number; skippedCount: number };
};
const r = res.data;
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
@@ -55,10 +96,47 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
}
};
const downloadReleaseDocument = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' });
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Release paper preview failed',
description: extractErrorMessage(error),
});
} finally {
setBusyId(null);
}
};
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const response = (await storeMutation.mutateAsync(item.id)) as { data: WarehouseInventoryItem };
const stored = response.data;
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
});
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
switch (action) {
case 'store':
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
return storeInventory(item);
case 'reserve':
setReserveItem(item);
return;
@@ -92,15 +170,41 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b>
</Text>
<Button
size="compact-sm"
variant="light"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onView={setViewItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
allSelected={allSelected}
someSelected={someSelected}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
@@ -114,6 +218,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
<InspectionReportModal
opened={Boolean(inspectItem)}
onClose={() => setInspectItem(null)}

View File

@@ -4,8 +4,10 @@ import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ReleaseOrderModalProps {
opened: boolean;
@@ -17,6 +19,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const { toast } = useToast();
const releaseMutation = useReleaseInventory();
const [reference, setReference] = useState('');
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
@@ -24,36 +27,54 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const handleSubmit = async () => {
if (!item) return;
const pdfWindow = window.open('', '_blank');
try {
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
toast({ title: 'Release order issued' });
const released = await releaseMutation.mutateAsync({
id: item.id,
payload: { reference: reference.trim() || undefined },
});
const releasedItem = released.data;
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${releasedItem.booking?.reference ?? releasedItem.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: 'Release exit paper issued',
description: opened
? 'The PDF opened in a browser tab for printing or saving.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
onClose();
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
} finally {
setDownloading(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Records the delivery order / release order sent to the customer. Once issued, the goods can be
picked up and delivered.
Creates the warehouse release document with booking, customer, cargo and location details. The
printed paper authorizes the goods to leave the warehouse gate.
</Text>
</Alert>
<TextInput
label="Release order reference"
placeholder="e.g. DO-2026-001"
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
Issue release order
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Issue & view exit paper
</Button>
</Group>
</Stack>

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
@@ -31,56 +31,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{warehouses.map((warehouse) => (
<Card key={warehouse.id} withBorder radius="md" padding="lg">
<Stack gap="sm">
<Card
key={warehouse.id}
withBorder
radius="md"
padding={0}
style={{
overflow: 'hidden',
borderColor: 'var(--mantine-color-gray-2)',
background: 'white',
}}
>
<Box h={3} bg={warehouse.status === 'ACTIVE' ? 'green.5' : 'gray.4'} />
<Stack gap="md" p="lg">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div>
<Text fw={700}>{warehouse.name}</Text>
<Text size="xs" c="dimmed">
{warehouse.code}
</Text>
</div>
<WarehouseStatusBadge status={warehouse.status} />
</Group>
<Group gap="xs">
<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 gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 38,
height: 38,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'var(--mantine-color-green-0)',
color: 'var(--mantine-color-green-7)',
border: '1px solid var(--mantine-color-green-2)',
}}
>
<Building2 size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={800} size="md" truncate>
{warehouse.name}
</Text>
<Text size="xs" c="dimmed" fw={600} truncate>
{warehouse.code}
</Text>
</Box>
</Group>
)}
{warehouse.locationName && (
<Group gap={6} c="dimmed">
<MapPin size={14} />
<Text size="sm">{warehouse.locationName}</Text>
</Group>
)}
<Group justify="space-between">
<Text size="xs" c="dimmed">
Weight
</Text>
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Containers
</Text>
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
<Stack gap={6} align="flex-end">
<WarehouseStatusBadge status={warehouse.status} />
<WarehouseTypeBadge type={warehouse.type} />
</Stack>
</Group>
<Group justify="flex-end" gap="xs" mt="xs">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<Stack gap={8}>
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
<Group gap={8} c="dimmed" wrap="nowrap">
<Building2 size={15} />
<Text size="sm" truncate>
{stationNameById.get(warehouse.stationId)}
</Text>
</Group>
)}
{warehouse.locationName && (
<Group gap={8} c="dimmed" wrap="nowrap">
<MapPin size={15} />
<Text size="sm" truncate>
{warehouse.locationName}
</Text>
</Group>
)}
</Stack>
<Divider />
<Stack gap="md">
<CapacityRow
icon={Weight}
label="Weight"
current={warehouse.currentWeight}
capacity={warehouse.capacityWeight}
/>
<CapacityRow
icon={Package}
label="Containers"
current={warehouse.currentContainers}
capacity={warehouse.capacityContainers}
/>
</Stack>
<Group justify="flex-end" gap="xs" mt="auto">
<Tooltip label="View warehouse" withArrow>
<ActionIcon variant="light" color="gray" onClick={() => onView(warehouse)} aria-label="View warehouse">
<Eye size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit warehouse" withArrow>
<ActionIcon variant="light" color="orange" onClick={() => onEdit(warehouse)} aria-label="Edit warehouse">
<Pencil size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Stack>
</Card>
@@ -88,3 +133,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
</SimpleGrid>
);
}
const capacityPercent = (current?: number | null, capacity?: number | null) => {
if (!capacity || capacity <= 0) return 0;
return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100));
};
function CapacityRow({
icon: Icon,
label,
current,
capacity,
}: {
icon: typeof Weight;
label: string;
current?: number | null;
capacity?: number | null;
}) {
const percent = capacityPercent(current, capacity);
const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green';
return (
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap">
<Group gap={7} c="dimmed" wrap="nowrap">
<Icon size={15} />
<Text size="xs" fw={700} tt="uppercase">
{label}
</Text>
</Group>
<Text size="sm" fw={700}>
{formatCapacity(current, capacity)}
</Text>
</Group>
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
</Stack>
);
}

View File

@@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps {
}
const ORANGE = '#f08c00';
const GREEN = '#5bbf4a';
const GREEN = '#22c55e'; // green from bookings
/** Inventory lifecycle status series — alternating orange / light green. */
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
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 },
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
] as const;
type Granularity = 'week' | 'month' | 'year';
@@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry, i) => (
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip />

View File

@@ -1,4 +1,5 @@
import { Stack, Table, Text } from '@mantine/core';
import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
import { Eye } from 'lucide-react';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -6,6 +7,7 @@ import { formatDate, formatNumber } from './options';
interface WarehouseInquiryTableProps {
results: InventoryInquiryResult[];
onView?: (result: InventoryInquiryResult) => void;
}
const itemDescriptor = (result: InventoryInquiryResult) => {
@@ -16,7 +18,7 @@ const itemDescriptor = (result: InventoryInquiryResult) => {
return '—';
};
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
if (results.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -36,11 +38,13 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
{onView && <Table.Th ta="right">Actions</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -48,7 +52,7 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? '—'}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? '—'}</Table.Td>
@@ -66,7 +70,24 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={result.status} />
<Stack gap={0}>
<Text size="sm">{result.locationSummary ?? '-'}</Text>
{result.trainNumber && (
<Text size="xs" c="dimmed">
{result.trainNumber}
{result.route ? ` - ${result.route}` : ''}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td>
{result.status ? (
<InventoryStatusBadge status={result.status} />
) : (
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'} size="sm">
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
</Badge>
)}
</Table.Td>
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
<Table.Td>{formatNumber(result.weight)}</Table.Td>
@@ -76,6 +97,15 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
<Table.Td>
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
</Table.Td>
{onView && (
<Table.Td>
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(result)} ml="auto">
<Eye size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
@@ -12,8 +12,18 @@ interface WarehouseInventoryTableProps {
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
onView?: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void;
allSelected?: boolean;
someSelected?: boolean;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -40,9 +50,18 @@ export function WarehouseInventoryTable({
onAdvance,
onMove,
onHistory,
onView,
onInspect,
onFeePreview,
onReleaseDocument,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -56,6 +75,16 @@ export function WarehouseInventoryTable({
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
{selectable && (
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={onToggleSelectAll}
/>
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
@@ -76,6 +105,15 @@ export function WarehouseInventoryTable({
const nextAction = getNextInventoryAction(item);
return (
<Table.Tr key={item.id}>
{selectable && (
<Table.Td>
<Checkbox
aria-label={`Select ${item.bookingId ?? item.id}`}
checked={selectedIds?.has(item.id) ?? false}
onChange={() => onToggleSelect?.(item.id)}
/>
</Table.Td>
)}
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
@@ -108,6 +146,13 @@ export function WarehouseInventoryTable({
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{onView && (
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
)}
{nextAction && (
<Button
size="compact-xs"
@@ -119,6 +164,30 @@ export function WarehouseInventoryTable({
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* Batch 10 — a PICKUP_READY import item can also be stored or dispatched,
kept separate from the customer-pickup (release/deliver) next action. */}
{item.status === 'READY_FOR_PICKUP' && (
<>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
@@ -140,6 +209,20 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onReleaseDocument && item.releaseDate && (
<Tooltip label="View release exit paper" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onReleaseDocument(item)}>
<FileText size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
<MapPin size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />

View File

@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',

View File

@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryDetailModal } from './InventoryDetailModal';
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect';
export { WagonSelect } from './WagonSelect';

View File

@@ -0,0 +1,24 @@
export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) {
const url = URL.createObjectURL(blob);
if (targetWindow && !targetWindow.closed) {
targetWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return true;
}
const opened = window.open(url, '_blank');
if (opened) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return true;
}
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return false;
}