mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
merge
This commit is contained in:
@@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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