mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
changes
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user