This commit is contained in:
marshal
2026-06-25 10:37:21 +03:00
295 changed files with 16865 additions and 7724 deletions

View File

@@ -1,77 +1,16 @@
import { useMemo, useState } from "react";
import { ArrowRight, Building2, Package } from "lucide-react";
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Calendar, Package, User } from "lucide-react";
import { Group } from "@mantine/core";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
function BookingQueueRow({
booking,
selected,
disabled,
onToggle,
}: {
booking: BookingListRow;
selected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
<Package size={14} />
<Text fw={600} size="sm">{booking.reference}</Text>
{booking.isGovernment ? (
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
Government
</Badge>
) : null}
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
<Group gap={6}>
<Text size="xs">{booking.originLabel}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destinationLabel}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore} />
{booking.serviceTypeLabel ? (
<Text size="xs" c="dimmed">
{booking.serviceTypeLabel}
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
import { cn } from "@/lib/utils";
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
export function OperationsBookingQueue({
bookings,
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
isLoading?: boolean;
onAllocate: (bookingIds: string[]) => void;
}) {
const { government, commercial } = useMemo(
() => groupBookingsForOperationsQueue(bookings),
[bookings],
const navigate = useNavigate();
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
window.setTimeout(() => {
suppressRowClickRef.current = false;
}, 400);
}, []);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
navigate(`/dashboard/booking-requests/${row.id}`);
},
[navigate],
);
const [govSelected, setGovSelected] = useState<string[]>([]);
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
const allocatable = (row: BookingListRow) =>
row.status === "PAID" &&
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
const govSelection = govSelected.length
? govSelected
: government.filter(allocatable).map((b) => b.id);
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
const existing = selectedByBucket[bucketKey];
if (existing) return existing;
return bucketBookings.filter(allocatable).map((b) => b.id);
};
const toggleGov = (bookingId: string) => {
setGovSelected((prev) => {
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
return base.includes(bookingId)
? base.filter((id) => id !== bookingId)
: [...base, bookingId];
});
};
const toggleBucket = (bucketKey: string, bookingId: string) => {
setSelectedByBucket((prev) => {
const current = prev[bucketKey] ?? [];
const next = current.includes(bookingId)
? current.filter((id) => id !== bookingId)
: [...current, bookingId];
return { ...prev, [bucketKey]: next };
});
};
if (isLoading) {
return <Text size="sm" c="dimmed">Loading operations queue</Text>;
}
if (!government.length && !commercial.length) {
return (
<Text size="sm" c="dimmed">
No PAID bookings ready to allocate.
</Text>
);
}
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Package className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<Group gap={6} wrap="nowrap">
<p className="truncate font-medium text-foreground">{booking.reference}</p>
{booking.isGovernment ? (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
Government
</Badge>
) : null}
</Group>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{booking.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
{booking.tradeDirection}
</Badge>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{booking.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="space-y-1 py-1">
<BookingStatusBadge status={row.original.status} />
{row.original.schedulingStatus ? (
<SchedulingStatusBadge status={row.original.schedulingStatus} />
) : null}
</div>
),
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.scheduledDate}
</span>
),
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,
cell: ({ row }) => <BookingPriorityBadge score={row.original.priorityScore} />,
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{row.original.paymentCurrency}{" "}
{row.original.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
),
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
onAllocateBooking={() => onAllocate([row.original.id])}
/>
),
},
];
return (
<Stack gap="lg">
{government.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Title order={5}>Government priority</Title>
<Text size="xs" c="dimmed">
Served first not grouped by 3-hour window
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{govSelection.length} selected</Badge>
<Button
size="compact-sm"
color="violet"
disabled={!govSelection.length}
onClick={() => onAllocate(govSelection)}
>
Allocate
</Button>
</Group>
</Group>
<Stack gap="sm">
{government.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={govSelection.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleGov(booking.id)}
/>
))}
</Stack>
</Paper>
) : null}
{commercial.length > 0 ? (
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
{commercial.map((bucket) => {
const selected = bucketSelection(bucket.key, bucket.bookings);
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">{bucket.label}</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} commercial booking
{bucket.bookings.length === 1 ? "" : "s"}
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="edr-green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();
onAllocate(selected);
}}
>
Allocate
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={selected.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleBucket(bucket.key, booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : null}
</Stack>
<DataTable
columns={columns}
data={bookings}
status={isLoading ? "loading" : "success"}
emptyMessage="No PAID bookings ready to load."
onRowClick={handleRowClick}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
/>
);
}

View File

@@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage route definitions built from freight yards",
},
},
{
prefix: "/dashboard/warehouses/list",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards, and zones",
},
},
{
prefix: "/dashboard/warehouse-inventory",
meta: {
title: "Warehouse inventory",
subtitle: "Track received items through inspection and loading",
},
},
{
prefix: "/dashboard/inventory-inquiry",
meta: {
title: "Inventory inquiry",
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouse dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
...getFleetRouteMeta(),
{
prefix: "/dashboard/trains/",

View File

@@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
@@ -32,6 +34,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);
@@ -48,7 +53,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>
@@ -60,10 +65,12 @@ 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)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
</Stack>
)}
</Card>
@@ -106,7 +113,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)) {
@@ -121,11 +128,21 @@ 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 releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
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) });
}
};
@@ -165,7 +182,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,
@@ -12,10 +12,8 @@ import {
} from '@mantine/core';
import { Upload } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {
INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES,
@@ -47,12 +45,9 @@ const STATUS_LABELS: Record<InspectionResultStatus, string> = {
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
const { toast } = useToast();
const createReport = useMutation(
api.warehouses.createInspectionReport.mutationOptions(),
);
const uploadAttachments = useMutation(
api.warehouses.uploadInspectionAttachments.mutationOptions(),
);
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');
@@ -82,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

@@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
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';
@@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
@@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
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);
@@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
}
};
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 stored = await storeMutation.mutateAsync(item.id);
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;
@@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onView={setViewItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
@@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
<InspectionReportModal
opened={Boolean(inspectItem)}
onClose={() => setInspectItem(null)}

View File

@@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ReleaseOrderModalProps {
opened: boolean;
@@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
@@ -26,36 +29,53 @@ 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 },
});
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${released.booking?.reference ?? released.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 { useQuery } from '@tanstack/react-query';
@@ -35,56 +35,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>
@@ -92,3 +137,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(Number(current) || 0, capacity)}
</Text>
</Group>
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import { Stack, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
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';
@@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options';
interface WarehouseInquiryTableProps {
results: InventoryInquiryResult[];
onView?: (result: InventoryInquiryResult) => void;
}
const dash = '-';
const itemDescriptor = (result: InventoryInquiryResult) => {
if (result.containerNumber) return `Container ${result.containerNumber}`;
if (result.cargoType) return `Cargo · ${result.cargoType}`;
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
if (result.cargoType) return `Cargo - ${result.cargoType}`;
if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`;
if (result.goodsId) return 'Goods';
return '—';
return dash;
};
const columns: ColumnDef<InventoryInquiryResult>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
if (results.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No matching items. Adjust your search to locate cargo, containers or goods.
</Text>
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
{row.original.warehouse?.code && (
<Text size="xs" c="dimmed">
{row.original.warehouse.code}
</Text>
)}
</Stack>
),
},
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'ready',
header: 'Ready',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
},
];
);
}
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
return (
<DataTable
columns={columns}
data={results}
status="success"
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
containerClassName="border-0 shadow-none"
/>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Item</Table.Th>
<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> : null}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{results.map((result) => (
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? dash}</Table.Td>
<Table.Td>{itemDescriptor(result)}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.warehouse?.name ?? dash}</Text>
{result.warehouse?.code ? (
<Text size="xs" c="dimmed">
{result.warehouse.code}
</Text>
) : null}
</Stack>
</Table.Td>
<Table.Td>{result.yard?.name ?? dash}</Table.Td>
<Table.Td>{result.zone?.name ?? dash}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.locationSummary ?? dash}</Text>
{result.trainNumber ? (
<Text size="xs" c="dimmed">
{result.trainNumber}
{result.route ? ` - ${result.route}` : ''}
</Text>
) : null}
</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>
<Table.Td>
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
</Table.Td>
<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>
) : null}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,15 +1,13 @@
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
import { useMemo } from "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 {
INVENTORY_NEXT_ACTION,
type InventoryAction,
type WarehouseInventoryItem,
} from "@/types/warehouse";
import { InventoryStatusBadge } from "./badges";
import { formatDate, formatNumber, humanizeEnum } from "./options";
getNextInventoryAction,
type InventoryAction,
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -17,11 +15,11 @@ 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;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void;
@@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps {
}
const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: "Container", color: "blue" };
if (item.cargoId) return { label: "Cargo", color: "grape" };
if (item.goodsId) return { label: "Goods", color: "orange" };
return { label: "—", color: "gray" };
if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: '-', color: 'gray' };
};
const actionColor: Record<InventoryAction, string> = {
store: "blue",
reserve: "grape",
"ready-for-loading": "cyan",
load: "teal",
dispatch: "edr-green",
"ready-for-pickup": "orange",
release: "yellow",
deliver: "green",
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
};
export function WarehouseInventoryTable({
@@ -53,165 +51,195 @@ export function WarehouseInventoryTable({
onAdvance,
onMove,
onHistory,
onView,
onInspect,
onFeePreview,
onReleaseDocument,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [
{
id: "booking",
header: "Booking",
cell: ({ row }) =>
row.original.bookingId ? (
<Tooltip label={row.original.bookingId} withArrow>
<Text size="sm" fw={600}>
{row.original.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: "facility",
header: "Facility",
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
},
{
id: "warehouse",
header: "Warehouse",
cell: ({ row }) => row.original.warehouse?.code ?? "—",
},
{
id: "yard",
header: "Yard",
cell: ({ row }) => row.original.yard?.code ?? "—",
},
{
id: "zone",
header: "Zone",
cell: ({ row }) => row.original.zone?.code ?? "—",
},
{
id: "item",
header: "Item",
cell: ({ row }) => {
const kind = itemKind(row.original);
return (
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
);
},
},
{
id: "qty",
header: "Qty",
cell: ({ row }) => formatNumber(row.original.quantity),
},
{
id: "weight",
header: "Weight",
cell: ({ row }) => formatNumber(row.original.weight),
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<InventoryStatusBadge status={row.original.status} />
),
},
{
id: "arrived",
header: "Arrived",
cell: ({ row }) => (
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const item = row.original;
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Group
gap="xs"
justify="flex-end"
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, "_"))}
</Button>
)}
{item.status !== "DISPATCHED" && (
<Tooltip label="Move" withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => onMove(item)}
>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon
variant="subtle"
color="orange"
onClick={() => onInspect(item)}
>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon
variant="subtle"
color="teal"
onClick={() => onFeePreview(item)}
>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => onHistory(item)}
>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
],
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview],
);
const selectable = Boolean(onToggleSelect);
if (items.length === 0) {
return (
<Text size="sm" c="dimmed" ta="center" py="xl">
No inventory items found.
</Text>
);
}
return (
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No inventory items found."
containerClassName="border-0 shadow-none"
/>
<Table.ScrollContainer minWidth={1150}>
<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>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
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>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}...
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
-
</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>
<Table.Td>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
</Table.Td>
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={item.status} />
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</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"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{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)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</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} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,10 +1,20 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { ActionIcon, Group, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import {
Building2,
Eye,
MapPin,
Package,
Pencil,
Scale,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { useQuery } from '@tanstack/react-query';
import { bookingTable } from '@/components/bookings/booking-ui.styles';
import { api } from '@/services/api';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
onEdit: (warehouse: Warehouse) => void;
}
const HEADER = bookingTable.headerCell;
function CapacityCell({
current,
capacity,
icon,
}: {
current?: number | null;
capacity?: number | null;
icon: ReactNode;
}) {
const numericCurrent = Number(current) || 0;
const numericCapacity = Number(capacity) || 0;
const hasCapacity = numericCapacity > 0;
const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0;
const isOverCapacity = hasCapacity && numericCurrent > numericCapacity;
return (
<div className="min-w-[8rem] space-y-2 py-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/70 text-muted-foreground">
{icon}
</span>
<span className="whitespace-nowrap">{formatCapacity(numericCurrent, capacity)}</span>
</div>
{hasCapacity ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
className={isOverCapacity ? 'h-full rounded-full bg-red-500' : 'h-full rounded-full bg-edr-green'}
style={{ width: `${ratio}%` }}
/>
</div>
) : null}
</div>
);
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
const columns: ColumnDef<Warehouse>[] = [
{
id: 'code',
header: 'Code',
header: () => <span className={HEADER}>Warehouse</span>,
cell: ({ row }) => (
<Text
fw={600}
size="sm"
c="edr-green.7"
style={{ cursor: 'pointer' }}
onClick={() => onView(row.original)}
>
{row.original.code}
</Text>
<div className="flex min-w-[11rem] items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<WarehouseIcon className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<button
type="button"
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
onClick={(e) => {
e.stopPropagation();
onView(row.original);
}}
>
{row.original.code}
</button>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{row.original.name}
</p>
</div>
</div>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{
id: 'facility',
header: 'Facility',
header: () => <span className={HEADER}>Facility</span>,
cell: ({ row }) => {
const name = row.original.stationId
? stationNameById.get(row.original.stationId)
: undefined;
return name ? (
<Text size="sm" fw={500}>
{name}
</Text>
<div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{name}</span>
</div>
) : (
<Text size="sm" c="dimmed">
-
</Text>
);
},
},
{
id: 'type',
header: 'Type',
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
header: () => <span className={HEADER}>Type</span>,
cell: ({ row }) => (
<div className="py-1">
<WarehouseTypeBadge type={row.original.type} />
</div>
),
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => row.original.locationName ?? '—',
header: () => <span className={HEADER}>Location</span>,
cell: ({ row }) => (
<div className="flex min-w-[10rem] items-center gap-2 py-1 text-sm text-foreground">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{row.original.locationName ?? '-'}</span>
</div>
),
},
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
header: () => <span className={HEADER}>Weight</span>,
cell: ({ row }) => (
<CapacityCell
current={row.original.currentWeight}
capacity={row.original.capacityWeight}
icon={<Scale className="size-3.5" />}
/>
),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
header: () => <span className={HEADER}>Containers</span>,
cell: ({ row }) => (
<CapacityCell
current={row.original.currentContainers}
capacity={row.original.capacityContainers}
icon={<Package className="size-3.5" />}
/>
),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
header: () => <span className={HEADER}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<WarehouseStatusBadge status={row.original.status} />
</div>
),
},
{
id: 'actions',
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
status="success"
onRowClick={(warehouse) => onView(warehouse)}
emptyMessage="No warehouses found."
containerClassName="border-0 shadow-none"
containerClassName="border-0 bg-transparent shadow-none"
/>
);
}

View File

@@ -57,6 +57,8 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: "yellow",
STORED: "blue",
RESERVED: "grape",
ARRIVED_AT_WAREHOUSE: "orange",
UNDER_INSPECTION: "yellow",
READY_FOR_LOADING: "cyan",
LOADED: "teal",
READY_FOR_PICKUP: "teal",

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;
}