mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
merge conflict resolved
This commit is contained in:
@@ -96,6 +96,8 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
|
||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||
import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage";
|
||||
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
@@ -708,6 +710,29 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="empty-return-requests"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
]}
|
||||
>
|
||||
<EmptyReturnRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="register-full-containers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.warehouseInventory.receive}
|
||||
>
|
||||
<RegisterFullContainersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="loaded-inventory"
|
||||
element={
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { POSITION_COOKIE } from "@/shared/utils/positionCookie";
|
||||
|
||||
const DEFAULT_PATH = "/";
|
||||
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
@@ -32,6 +34,8 @@ export const clearSessionCookies = () => {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
AUTH_USER_COOKIE,
|
||||
POSITION_COOKIE,
|
||||
// Pre-rename name, still cleared so a stale value cannot outlive logout.
|
||||
"current-position-id",
|
||||
"selected-position-id",
|
||||
].forEach(clearCookie);
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { Banknote, Receipt } from "lucide-react";
|
||||
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { detailStyles } from "./detail/booking-detail.styles";
|
||||
|
||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
// The booking's own freight invoice: source `booking`, sourceId = booking id
|
||||
// (which `search` matches). Newest first — a re-issue supersedes the old one.
|
||||
const invoiceQuery = useQuery(
|
||||
api.invoices.list.queryOptions({
|
||||
input: {
|
||||
filter: {
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
sources: "booking",
|
||||
search: booking.id,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null;
|
||||
|
||||
const computed = Number(booking.totalAmount);
|
||||
// The booking price is computed from the contract and is NOT staff-editable.
|
||||
// A historical `adjustedTotalAmount` (from before adjustments were removed)
|
||||
@@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{invoiceNumber && <Row label="Invoice number" value={invoiceNumber} mono />}
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{lineItems.length > 0 && (
|
||||
|
||||
@@ -1199,7 +1199,7 @@ export default function GlCreateBookingForm() {
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
sealNumber: u.sealNumber.trim(),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
// Per-container handling — the server rolls these into the line
|
||||
// counts and bills each surcharge on the ticked containers only.
|
||||
@@ -1261,7 +1261,7 @@ export default function GlCreateBookingForm() {
|
||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
sealNumber: u.sealNumber.trim(),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
|
||||
@@ -151,6 +151,11 @@ export async function parseContainerExcel(
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sealNumber = cell("sealNumber");
|
||||
if (!sealNumber) {
|
||||
errors.push(`Row ${rowNo}: seal number is required.`);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
sealNumber,
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react";
|
||||
|
||||
import type { CustomerAccount } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** First letters of the person's name; falls back to the login initial. */
|
||||
function initials(account: CustomerAccount): string {
|
||||
const letters = [account.firstName, account.lastName]
|
||||
.map((n) => n?.trim()?.[0])
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return (letters || account.username?.[0] || "?").toUpperCase();
|
||||
}
|
||||
|
||||
/** A labelled value; rendered only when there is something to show. */
|
||||
function Field({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
after,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
after?: React.ReactNode;
|
||||
}) {
|
||||
if (!value?.trim()) return null;
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Box c="edr-muted" mt={2}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{after}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One portal login belonging to a customer.
|
||||
*
|
||||
* Distinct from the contact details on the Overview tab: those are the business
|
||||
* contact info on the company row, this is the credential someone actually
|
||||
* signs in with — the two drift apart routinely, and staff answering "the
|
||||
* customer can't log in" need this one.
|
||||
*/
|
||||
export function AccountCard({ account }: { account: CustomerAccount }) {
|
||||
const name =
|
||||
`${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() ||
|
||||
account.username ||
|
||||
"Unnamed account";
|
||||
|
||||
// No IAM row at all — the profile points at a user that is gone. Treated as a
|
||||
// fault rather than a status: nothing below it can be trusted, so the card
|
||||
// says so once, loudly, instead of drawing empty credential fields.
|
||||
const orphaned = account.username === null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Avatar radius="xl" color="edr-green" variant="light">
|
||||
{initials(account)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
|
||||
{name}
|
||||
</Text>
|
||||
{account.isPrimaryContact && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Primary contact
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{account.jobTitle && (
|
||||
<Text size="xs" c="edr-muted">
|
||||
{account.jobTitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="wrap">
|
||||
{orphaned ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<ShieldAlert size={11} />}
|
||||
>
|
||||
No IAM account
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={account.isActive ? "edr-green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{account.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{account.status && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{humanize(account.status)}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Created but never activated by its owner — usually the actual
|
||||
answer to "they say they never got in". */}
|
||||
{account.hasSetPassword === false && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Password never set
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{account.onboardingCompleted ? (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Onboarding submitted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Onboarding
|
||||
{account.onboardingStep
|
||||
? ` · ${humanize(account.onboardingStep)}`
|
||||
: " in progress"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!orphaned && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Field
|
||||
icon={<UserRound size={14} />}
|
||||
label="Username"
|
||||
value={account.username}
|
||||
/>
|
||||
<Field
|
||||
icon={<AtSign size={14} />}
|
||||
label="Email"
|
||||
value={account.email}
|
||||
/>
|
||||
<Field
|
||||
icon={<Phone size={14} />}
|
||||
label="Phone"
|
||||
value={account.phoneNumber}
|
||||
after={
|
||||
account.phoneVerified === false ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Unverified
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="edr-muted">
|
||||
Created {formatDate(account.createdAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountCard;
|
||||
@@ -174,6 +174,10 @@ export function DiffRow({
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
// No real "before" (field went from unset straight to a value, e.g. the
|
||||
// onboarding wizard's first save) — show the value alone rather than a
|
||||
// fake "— → value" that implies a prior state that never existed.
|
||||
const hadBefore = from !== "—";
|
||||
const changed = from !== to;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -181,24 +185,24 @@ export function DiffRow({
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
{changed && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
{hadBefore && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
)}
|
||||
{hadBefore && changed && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" fw={changed ? 600 : undefined} c={changed ? "edr-text" : "dimmed"}>
|
||||
{to}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
|
||||
|
||||
export interface TableCardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional heading row (title, chips, actions). Rendered in its own padded
|
||||
* section above the table and OUTSIDE the scroll region — a header inside it
|
||||
* would slide away from its own table on a narrow viewport.
|
||||
*/
|
||||
header?: ReactNode;
|
||||
/**
|
||||
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
|
||||
* always `width: 100%`, so without a floor it can never overflow its
|
||||
@@ -14,14 +20,27 @@ export interface TableCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
|
||||
* single child is a horizontally scrollable region. Pair with the table's
|
||||
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
|
||||
* the customer pages reads identically (same surface, same scroll behaviour).
|
||||
* Flush card shell for a `DataTable`: a padding-less card whose table region
|
||||
* runs edge to edge. Padding is applied per section rather than to the card, so
|
||||
* the optional {@link TableCardProps.header} is inset like any other card
|
||||
* content while the table's own rows and header cells reach both edges.
|
||||
*
|
||||
* Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"`
|
||||
* so every table on the customer pages reads identically (same surface, same
|
||||
* scroll behaviour).
|
||||
*/
|
||||
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
|
||||
export function TableCard({
|
||||
children,
|
||||
minWidth = 860,
|
||||
header,
|
||||
}: TableCardProps) {
|
||||
return (
|
||||
<Card p={0}>
|
||||
{header && (
|
||||
<Box p="md" style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}>
|
||||
{header}
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={minWidth}>{children}</Box>
|
||||
</Box>
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { AccountCard } from "./AccountCard";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
|
||||
import { PositionSelect } from "@/record-management/components/positionSelection";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
@@ -111,6 +112,10 @@ const FreightDashboardHeader = ({
|
||||
renders only during a review phase that still has undecided
|
||||
requests, so it never competes for space otherwise. */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
{/* Staff holding two posts switch desks here. Renders nothing for the
|
||||
single-position majority, so it costs the header no space. */}
|
||||
<PositionSelect />
|
||||
|
||||
<DocReviewAlertButton />
|
||||
|
||||
<Tooltip label="Language" withArrow openDelay={300}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackagePlus,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
Stamp,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Undo2,
|
||||
Users,
|
||||
Wallet,
|
||||
LifeBuoy,
|
||||
@@ -366,6 +368,24 @@ export const buildSidebarSections = (
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Empty Return Requests",
|
||||
href: "/dashboard/empty-return-requests",
|
||||
icon: <Undo2 />,
|
||||
// Visible to whoever already runs container returns, OR'd with
|
||||
// the queue's own key — so the dedicated permission can be handed
|
||||
// out per position later without the menu disappearing now.
|
||||
permission: [
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Register Full Containers",
|
||||
href: "/dashboard/register-full-containers",
|
||||
icon: <PackagePlus />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.receive,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { localNowForInput } from "@/lib/format";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations";
|
||||
|
||||
import { parseContainerReturnExcel, type ParsedReturnRow } from "./container-return-excel";
|
||||
import { useCompanyOptions } from "./useCompanyOptions";
|
||||
|
||||
interface BulkContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill of empties physically in a yard but never entered in the system.
|
||||
* The sheet carries per-container detail; the fields above the file are the
|
||||
* defaults for every row whose cell is blank, so the common case is a sheet of
|
||||
* container numbers plus one warehouse picked here.
|
||||
*/
|
||||
export default function BulkContainerReturnModal({
|
||||
opened,
|
||||
onClose,
|
||||
onUploaded,
|
||||
}: BulkContainerReturnModalProps) {
|
||||
const { toast } = useToast();
|
||||
const companies = useCompanyOptions();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [rows, setRows] = useState<ParsedReturnRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
|
||||
const [company, setCompany] = useState("");
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [returnDate, setReturnDate] = useState(localNowForInput());
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: () => warehouseService.list({}),
|
||||
});
|
||||
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
|
||||
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouseId]);
|
||||
useEffect(() => setZoneId(null), [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
|
||||
: [];
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const defaults = useMemo(
|
||||
() => ({
|
||||
facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "",
|
||||
yard: yards?.find((y) => y.id === yardId)?.name ?? "",
|
||||
zone: zones?.find((z) => z.id === zoneId)?.name ?? "",
|
||||
}),
|
||||
[warehouses, warehouseId, yards, yardId, zones, zoneId],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
};
|
||||
|
||||
const handleFile = async (next: File | null) => {
|
||||
setFile(next);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
if (!next) return;
|
||||
setParsing(true);
|
||||
const result = await parseContainerReturnExcel(next);
|
||||
setParsing(false);
|
||||
setRows(result.rows);
|
||||
setParseErrors(result.errors);
|
||||
};
|
||||
|
||||
// Row cell wins; the field above the file fills the blanks.
|
||||
const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => {
|
||||
const companyName = row.companyName || company;
|
||||
return {
|
||||
containerNumber: row.containerNumber,
|
||||
containerSize: row.containerSize ?? undefined,
|
||||
companyName: companyName || undefined,
|
||||
customerId: companyName ? companies.resolveId(companyName) : undefined,
|
||||
returnedBy: row.returnedBy ?? returnedBy ?? undefined,
|
||||
returnDate: row.returnDate ?? new Date(returnDate).toISOString(),
|
||||
facility: row.facility || defaults.facility || undefined,
|
||||
yard: row.yard || defaults.yard || undefined,
|
||||
zone: row.zone || defaults.zone || undefined,
|
||||
condition: row.condition || undefined,
|
||||
handoverNote: row.handoverNote || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)),
|
||||
onSuccess: (created) => {
|
||||
toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` });
|
||||
reset();
|
||||
onUploaded();
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Bulk upload failed",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Every row needs a warehouse from somewhere — the API stores facility as
|
||||
// free text, so a blank one would silently produce unplaceable containers.
|
||||
const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length;
|
||||
const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length;
|
||||
const blockers = [
|
||||
missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null,
|
||||
missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
title="Bulk Upload Container Returns"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
For empties already sitting in the yard but not yet on the system. Values below fill any
|
||||
blank cell in the sheet.
|
||||
</Text>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(v) => setReturnedBy(v as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Truck" },
|
||||
{ value: "CUSTOMER", label: "Customer Truck" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Select warehouse"
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
data={warehouseOptions}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouseId}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FileInput
|
||||
label="Excel file"
|
||||
placeholder="Select .xlsx or .xls"
|
||||
accept=".xlsx,.xls"
|
||||
leftSection={<Upload size={16} />}
|
||||
value={file}
|
||||
onChange={(next) => void handleFile(next)}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Returned Date & Time (default)
|
||||
</Text>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{parsing && <Text size="sm">Reading file…</Text>}
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was imported`}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<List size="sm">
|
||||
{parseErrors.map((err) => (
|
||||
<List.Item key={err}>{err}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{blockers.length > 0 && (
|
||||
<Alert color="yellow" title="Fill these in before uploading">
|
||||
<List size="sm">
|
||||
{blockers.map((b) => (
|
||||
<List.Item key={b}>{b}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Preview
|
||||
</Text>
|
||||
<Badge size="sm">{rows.length} containers</Badge>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const payload = toPayload(row);
|
||||
return (
|
||||
<Table.Tr key={row.containerNumber}>
|
||||
<Table.Td>{payload.containerNumber}</Table.Td>
|
||||
<Table.Td>{payload.containerSize ? `${payload.containerSize} ft` : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">{payload.companyName || "—"}</Text>
|
||||
{payload.companyName && !payload.customerId && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.returnedBy ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{payload.returnDate
|
||||
? new Date(payload.returnDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.facility ?? "—"}</Table.Td>
|
||||
<Table.Td>{payload.yard ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
disabled={rows.length === 0 || blockers.length > 0}
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
Upload {rows.length > 0 ? `${rows.length} containers` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,19 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
|
||||
import type {
|
||||
SaveWarehousePayload,
|
||||
Warehouse,
|
||||
WarehouseFreightType,
|
||||
WarehouseType,
|
||||
} from '@/types/warehouse';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
lettersOnly,
|
||||
statusOptions,
|
||||
warehouseFreightTypeOptions,
|
||||
warehouseTypeOptions,
|
||||
} from './options';
|
||||
|
||||
interface CreateWarehouseModalProps {
|
||||
opened: boolean;
|
||||
@@ -26,6 +37,7 @@ interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
freightType: WarehouseFreightType | null;
|
||||
stationId: string | null;
|
||||
locationName: string;
|
||||
capacityWeight: number | '';
|
||||
@@ -38,6 +50,7 @@ const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'OPEN_WAREHOUSE',
|
||||
freightType: null,
|
||||
stationId: null,
|
||||
locationName: '',
|
||||
capacityWeight: '',
|
||||
@@ -66,6 +79,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: warehouse.name,
|
||||
code: warehouse.code,
|
||||
type: warehouse.type,
|
||||
freightType: warehouse.freightType ?? null,
|
||||
stationId: warehouse.stationId ?? null,
|
||||
locationName: warehouse.locationName ?? '',
|
||||
capacityWeight: warehouse.capacityWeight ?? '',
|
||||
@@ -90,6 +104,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
freightType: form.freightType,
|
||||
stationId: form.stationId ?? undefined,
|
||||
locationName: form.locationName.trim() || undefined,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
@@ -142,6 +157,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Freight type"
|
||||
placeholder="Both"
|
||||
data={warehouseFreightTypeOptions}
|
||||
value={form.freightType}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: value as WarehouseFreightType | null }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
data={warehouseTypeOptions}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TrainLoadableItem,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
RECEIVED: 'blue',
|
||||
@@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<YardLoadingWindows
|
||||
scheduleId={train.scheduleId}
|
||||
items={items}
|
||||
logs={train.stationWorkLogs}
|
||||
/>
|
||||
{bookings.map((b) => (
|
||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
|
||||
import { Alert, Button, Group, List, Modal, Select, Stack, Textarea, Text } from '@mantine/core';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface MoveInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -20,6 +22,7 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,10 +30,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// A container with boxes stacked on top of it cannot be lifted out — the API
|
||||
// refuses the move, so the button says why instead of firing a 409.
|
||||
const accessibilityQuery = useQuery(
|
||||
api.warehouses.containerAccessibility.queryOptions({
|
||||
input: { id: item?.id ?? '' },
|
||||
enabled: opened && Boolean(item?.id),
|
||||
}),
|
||||
);
|
||||
const accessibility = accessibilityQuery.data;
|
||||
const blocked = accessibility ? !accessibility.accessible : false;
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
@@ -69,7 +84,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
|
||||
payload: {
|
||||
warehouseId,
|
||||
yardId,
|
||||
zoneId,
|
||||
slotId: slotId || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory moved' });
|
||||
onClose();
|
||||
@@ -81,6 +102,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
{blocked && accessibility ? (
|
||||
<Alert icon={<Layers size={16} />} color="orange" variant="light" title="Container is buried">
|
||||
<Text size="sm">
|
||||
It sits at {accessibility.stackCode} level {accessibility.level} with{' '}
|
||||
{accessibility.blockingContainers.length} container(s) stacked on top. Move these out
|
||||
first:
|
||||
</Text>
|
||||
<List size="sm" mt={4}>
|
||||
{accessibility.blockingContainers.map((b) => (
|
||||
<List.Item key={b.inventoryId}>
|
||||
{b.containerNumber ?? 'Container'} — level {b.level}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Select
|
||||
label="Destination warehouse"
|
||||
placeholder="Select warehouse"
|
||||
@@ -115,8 +152,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} label="Destination stack position" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Reason for the move"
|
||||
@@ -129,7 +171,12 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={moveMutation.isPending}
|
||||
disabled={blocked}
|
||||
title={blocked ? 'Containers stacked above this one must be moved first' : undefined}
|
||||
>
|
||||
Move inventory
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import { bookingsService } from '@/services/bookings.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
@@ -90,6 +91,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
@@ -857,6 +859,8 @@ function EligibleTab({
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
|
||||
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
|
||||
|
||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||
const canReceiveBooking = (row: EligibleBooking) =>
|
||||
@@ -943,6 +947,105 @@ function EligibleTab({
|
||||
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
|
||||
const pendingUsesFirstMile =
|
||||
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
|
||||
const pendingContainerBooking =
|
||||
pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER'
|
||||
? pendingReceiveRows[0]
|
||||
: null;
|
||||
const { data: assignedCustomerTrucks = [] } = useQuery({
|
||||
queryKey: ['receive-customer-trucks', pendingContainerBooking?.id],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string),
|
||||
enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile,
|
||||
});
|
||||
const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter(
|
||||
(unit) => !unit.received,
|
||||
);
|
||||
const selectedCustomerTruck = assignedCustomerTrucks.find(
|
||||
(truck) => truck.id === selectedCustomerTruckId,
|
||||
);
|
||||
const assignedNumbersForSelectedTruck = new Set(
|
||||
(selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()),
|
||||
);
|
||||
const selectableContainerUnits = pendingContainerUnits.filter(
|
||||
(unit) =>
|
||||
assignedNumbersForSelectedTruck.size === 0 ||
|
||||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()),
|
||||
);
|
||||
const selectedContainerUnits = pendingContainerUnits.filter((unit) =>
|
||||
selectedContainerNumbers.includes(unit.containerNumber),
|
||||
);
|
||||
const selectedContainerWeight = selectedContainerUnits.reduce(
|
||||
(total, unit) => total + Number(unit.weightTons || 0),
|
||||
0,
|
||||
);
|
||||
const containerCapacityError =
|
||||
selectedContainerNumbers.length > 2
|
||||
? 'A truck carries no more than 2 containers.'
|
||||
: selectedContainerNumbers.length > 1 &&
|
||||
selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20'))
|
||||
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return;
|
||||
if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return;
|
||||
const pendingNumbers = new Set(
|
||||
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
|
||||
);
|
||||
const truck =
|
||||
assignedCustomerTrucks.find(
|
||||
(candidate) =>
|
||||
!candidate.arrivedAt &&
|
||||
(candidate.containers ?? []).some((container) =>
|
||||
pendingNumbers.has(container.containerNumber.toUpperCase()),
|
||||
),
|
||||
) ?? assignedCustomerTrucks[0];
|
||||
const truckContainers = (truck.containers ?? [])
|
||||
.map((container) => container.containerNumber.toUpperCase())
|
||||
.filter((number) => pendingNumbers.has(number));
|
||||
setSelectedCustomerTruckId(truck.id);
|
||||
setSelectedContainerNumbers(truckContainers);
|
||||
setTruckForm((current) => ({
|
||||
...current,
|
||||
truckPlateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName,
|
||||
truckType: truck.truckType,
|
||||
assignedEquipmentNumber: truckContainers.join(', '),
|
||||
unitCount: truckContainers.length,
|
||||
netWeightKg: pendingContainerUnits
|
||||
.filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase()))
|
||||
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0),
|
||||
}));
|
||||
setLockedTruckFields((current) => ({
|
||||
...current,
|
||||
truckPlateNumber: true,
|
||||
driverName: true,
|
||||
truckType: true,
|
||||
assignedEquipmentNumber: true,
|
||||
unitCount: true,
|
||||
}));
|
||||
}, [
|
||||
assignedCustomerTrucks,
|
||||
pendingContainerBooking,
|
||||
pendingContainerUnits,
|
||||
pendingUsesFirstMile,
|
||||
selectedCustomerTruckId,
|
||||
truckOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!truckOpen || !pendingContainerBooking) return;
|
||||
setTruckForm((current) => ({
|
||||
...current,
|
||||
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
|
||||
unitCount: selectedContainerNumbers.length,
|
||||
netWeightKg: selectedContainerWeight,
|
||||
}));
|
||||
}, [
|
||||
pendingContainerBooking,
|
||||
selectedContainerNumbers,
|
||||
selectedContainerWeight,
|
||||
truckOpen,
|
||||
]);
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
@@ -953,29 +1056,60 @@ function EligibleTab({
|
||||
return next;
|
||||
});
|
||||
|
||||
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
|
||||
const receiveBookings = async (
|
||||
bookingIds: string[],
|
||||
truckEntrance?: TruckEntrancePayload,
|
||||
containerNumbers?: string[],
|
||||
) => {
|
||||
const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null;
|
||||
const grnWindow = documentBookingId ? window.open('', '_blank') : null;
|
||||
const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null;
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds,
|
||||
...(containerNumbers?.length ? { containerNumbers } : {}),
|
||||
...(truckEntrance ? { truckEntrance } : {}),
|
||||
});
|
||||
const receivedProgress = r.results.find(
|
||||
(item) => item.receivedContainers != null && item.remainingContainers != null,
|
||||
);
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
|
||||
description: receivedProgress
|
||||
? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.`
|
||||
: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
||||
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
|
||||
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
grnWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
}
|
||||
try {
|
||||
const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId);
|
||||
const opened = openPdfBlob(
|
||||
acceptance,
|
||||
`carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`,
|
||||
acceptanceWindow,
|
||||
);
|
||||
toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' });
|
||||
} catch (error) {
|
||||
acceptanceWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Carriage acceptance sheet failed',
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
grnWindow?.close();
|
||||
acceptanceWindow?.close();
|
||||
}
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
@@ -983,8 +1117,12 @@ function EligibleTab({
|
||||
setReceivedAt(null);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
setSelectedCustomerTruckId(null);
|
||||
setSelectedContainerNumbers([]);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
grnWindow?.close();
|
||||
acceptanceWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
@@ -1011,6 +1149,18 @@ function EligibleTab({
|
||||
void receiveBookings(filteredIds);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
direction === 'EXPORT' &&
|
||||
selectedRows.some((row) => row.freightType === 'CONTAINER') &&
|
||||
selectedRows.length !== 1
|
||||
) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Receive one container booking per truck',
|
||||
description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
|
||||
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
|
||||
if (hasFirstMileRows && hasCustomerTruckRows) {
|
||||
@@ -1040,6 +1190,8 @@ function EligibleTab({
|
||||
...form,
|
||||
};
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setSelectedCustomerTruckId(null);
|
||||
setSelectedContainerNumbers([]);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(normalizedForm);
|
||||
setLockedTruckFields({
|
||||
@@ -1072,7 +1224,70 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
|
||||
return;
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
if (pendingContainerBooking && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' });
|
||||
return;
|
||||
}
|
||||
if (containerCapacityError) {
|
||||
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
|
||||
return;
|
||||
}
|
||||
await receiveBookings(
|
||||
pendingReceiveIds,
|
||||
toTruckEntrancePayload({
|
||||
...truckForm,
|
||||
...(pendingContainerBooking
|
||||
? {
|
||||
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
|
||||
unitCount: selectedContainerNumbers.length,
|
||||
netWeightKg: selectedContainerWeight,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
pendingContainerBooking ? selectedContainerNumbers : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const chooseCustomerTruck = (truckId: string | null) => {
|
||||
setSelectedCustomerTruckId(truckId);
|
||||
const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId);
|
||||
if (!truck) {
|
||||
setSelectedContainerNumbers([]);
|
||||
setLockedTruckFields((current) => ({
|
||||
...current,
|
||||
truckPlateNumber: false,
|
||||
driverName: false,
|
||||
truckType: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const pendingNumbers = new Set(
|
||||
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
|
||||
);
|
||||
const containers = (truck.containers ?? [])
|
||||
.map((container) => container.containerNumber.toUpperCase())
|
||||
.filter((number) => pendingNumbers.has(number));
|
||||
const weight = pendingContainerUnits
|
||||
.filter((unit) => containers.includes(unit.containerNumber.toUpperCase()))
|
||||
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0);
|
||||
setSelectedContainerNumbers(containers);
|
||||
setTruckForm((current) => ({
|
||||
...current,
|
||||
truckPlateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName,
|
||||
truckType: truck.truckType,
|
||||
assignedEquipmentNumber: containers.join(', '),
|
||||
unitCount: containers.length,
|
||||
netWeightKg: weight,
|
||||
}));
|
||||
setLockedTruckFields((current) => ({
|
||||
...current,
|
||||
truckPlateNumber: true,
|
||||
driverName: true,
|
||||
truckType: true,
|
||||
assignedEquipmentNumber: true,
|
||||
unitCount: true,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -1295,6 +1510,76 @@ function EligibleTab({
|
||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
{pendingContainerBooking && (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<PackageCheck size={16} />} color="teal" variant="light">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
· {Math.max(
|
||||
0,
|
||||
pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length,
|
||||
)} left after this receipt
|
||||
</Text>
|
||||
<Badge variant="light" color="blue">
|
||||
This truck: {selectedContainerNumbers.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Alert>
|
||||
{assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && (
|
||||
<Select
|
||||
label="Arriving assigned truck"
|
||||
description="Choose the physical truck at the gate; its assigned containers are selected below."
|
||||
placeholder="Select truck"
|
||||
data={assignedCustomerTrucks.map((truck) => ({
|
||||
value: truck.id,
|
||||
label: `${truck.plateNumber} · ${truck.driverName} · ${(truck.containers ?? [])
|
||||
.map((container) => container.containerNumber)
|
||||
.join(', ') || 'no containers'}`,
|
||||
}))}
|
||||
value={selectedCustomerTruckId}
|
||||
onChange={chooseCustomerTruck}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<MultiSelect
|
||||
label="Containers arriving on this truck"
|
||||
description="Required: select either 1 x 40ft container or up to 2 x 20ft containers."
|
||||
placeholder="Select the containers physically arriving"
|
||||
data={selectableContainerUnits.map((unit) => {
|
||||
const selected = selectedContainerNumbers.includes(unit.containerNumber);
|
||||
const selectedHasNon20 = selectedContainerUnits.some(
|
||||
(selectedUnit) => !String(selectedUnit.containerSize ?? '').includes('20'),
|
||||
);
|
||||
const candidateIs20 = String(unit.containerSize ?? '').includes('20');
|
||||
return {
|
||||
value: unit.containerNumber,
|
||||
label: `${unit.containerNumber} · ${unit.containerSize ?? 'size unknown'} · ${Number(
|
||||
unit.weightTons || 0,
|
||||
).toLocaleString()} t`,
|
||||
disabled:
|
||||
!selected &&
|
||||
(selectedContainerNumbers.length >= 2 ||
|
||||
(selectedContainerNumbers.length === 1 &&
|
||||
(selectedHasNon20 || !candidateIs20))),
|
||||
};
|
||||
})}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={setSelectedContainerNumbers}
|
||||
maxValues={2}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
{containerCapacityError && (
|
||||
<Alert color="red" variant="light">
|
||||
{containerCapacityError}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table withTableBorder highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
@@ -1353,7 +1638,9 @@ function EligibleTab({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Register Arrival & Generate GRN
|
||||
{pendingContainerBooking
|
||||
? 'Receive Selected Containers & Generate CAS + GRN'
|
||||
: 'Register Arrival & Generate GRN'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -1598,6 +1885,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const { data: pickerItems = [] } = useQuery({
|
||||
queryKey: ['train-loadable-items', targetScheduleId],
|
||||
queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!),
|
||||
enabled: Boolean(targetScheduleId) && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
@@ -1608,6 +1900,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
loadableIds = loadableIds.filter((id) => picked.has(id));
|
||||
}
|
||||
if (!loadableIds.length) {
|
||||
const scope = onlyIds.length
|
||||
? items.filter((i) => onlyIds.includes(i.id))
|
||||
: items.filter((i) => i.status === 'READY_FOR_LOADING');
|
||||
// A closed loading window is the blocker staff hit most, and the old
|
||||
// wagon-only message sent them to fix the wrong thing.
|
||||
const shut = scope.find((i) => !i.loadingWindowStarted);
|
||||
if (shut) {
|
||||
throw new Error(
|
||||
`Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
onlyIds.length
|
||||
? 'None of the selected items have an allocated wagon on this train'
|
||||
@@ -1701,6 +2004,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
searchable
|
||||
/>
|
||||
)}
|
||||
{targetScheduleId ? (
|
||||
<YardLoadingWindows
|
||||
scheduleId={targetScheduleId}
|
||||
items={pickerItems}
|
||||
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
|
||||
/>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -387,6 +387,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSizeByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), c.containerSize ?? '']),
|
||||
);
|
||||
// A truck may only carry out its OWN assigned containers — when the selected
|
||||
// truck has an assigned load, other trucks' containers are not offered.
|
||||
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
|
||||
@@ -398,7 +401,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
c.containerNumber,
|
||||
{
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
label: `${c.containerNumber} · ${c.containerSize ?? 'size unknown'} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
@@ -409,6 +412,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
|
||||
);
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedContainerSizes = selectedContainerNumbers.map(
|
||||
(number) => containerSizeByNumber.get(number.toUpperCase()) ?? '',
|
||||
);
|
||||
const containerCapacityError =
|
||||
selectedContainerNumbers.length > 2
|
||||
? 'A truck carries no more than 2 containers.'
|
||||
: selectedContainerNumbers.length > 1 && selectedContainerSizes.some((size) => !size.includes('20'))
|
||||
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
|
||||
: null;
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
@@ -470,6 +482,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && containerCapacityError) {
|
||||
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
@@ -636,14 +652,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label="Containers on this truck"
|
||||
description={
|
||||
isExitStep
|
||||
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||
: 'Containers this truck will carry.'
|
||||
? 'Select what is leaving: 1 x 40ft or up to 2 x 20ft. Their cargo weight must match gross - tare.'
|
||||
: 'Truck capacity: 1 x 40ft container or up to 2 x 20ft containers.'
|
||||
}
|
||||
placeholder="Select containers"
|
||||
searchable
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
maxValues={2}
|
||||
disabled={hasTruckLeft}
|
||||
/>
|
||||
) : (
|
||||
@@ -708,6 +725,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{containerCapacityError && (
|
||||
<Alert icon={<Info size={16} />} color="red" variant="light">
|
||||
<Text size="sm">{containerCapacityError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
interface SlotPickerProps {
|
||||
zoneId: string;
|
||||
value: string;
|
||||
onChange: (slotId: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack levels a container may actually be put on right now.
|
||||
*
|
||||
* Only the next fillable level of each stack is offered: a box cannot stand on
|
||||
* level 2 while level 1 is empty, so listing level 3 of an empty stack would
|
||||
* only produce a rejected request. The server enforces the same rule — this
|
||||
* mirrors it so the operator never sees a 400 for a position the form offered.
|
||||
*/
|
||||
export function fillableLevels(layout: ZoneLayout | undefined) {
|
||||
if (!layout) return [];
|
||||
|
||||
return layout.stacks
|
||||
.filter((stack) => stack.isActive && stack.status === 'ACTIVE')
|
||||
.flatMap((stack) => {
|
||||
const occupied = stack.slots
|
||||
.filter((slot) => slot.effectiveStatus === 'OCCUPIED')
|
||||
.map((slot) => slot.level);
|
||||
const top = occupied.length > 0 ? Math.max(...occupied) : 0;
|
||||
if (top >= stack.maxStackHeight) return [];
|
||||
|
||||
const next = stack.slots.find(
|
||||
(slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
|
||||
);
|
||||
if (!next) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: next.slotId,
|
||||
label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const options = useMemo(() => fillableLevels(data), [data]);
|
||||
|
||||
// A zone with no stacks configured keeps plain zone-level placement — showing
|
||||
// an empty picker there would imply a choice that does not exist.
|
||||
if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
description={
|
||||
options.length === 0 && !isLoading ? (
|
||||
<Text size="xs" c="orange">
|
||||
Every stack in this zone is full or blocked — the item will be stored at zone level.
|
||||
</Text>
|
||||
) : (
|
||||
'Leave blank to take the lowest free level automatically.'
|
||||
)
|
||||
}
|
||||
placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={disabled || isLoading || options.length === 0}
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlotPicker;
|
||||
@@ -8,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -25,12 +26,14 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
@@ -75,7 +78,7 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId, slotId: slotId || undefined } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
@@ -127,8 +130,13 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from '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 { Building2, Eye, MapPin, Package, Pencil, Trash2, Weight } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
/** Omitted when the user lacks the delete permission. */
|
||||
onDelete?: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit, onDelete }: WarehouseCardViewProps) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
);
|
||||
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{onDelete ? (
|
||||
<Tooltip label="Delete warehouse" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => onDelete(warehouse)}
|
||||
aria-label="Delete warehouse"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Package,
|
||||
Pencil,
|
||||
Scale,
|
||||
Trash2,
|
||||
Warehouse as WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
/** Omitted when the user lacks the delete permission. */
|
||||
onDelete?: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
const HEADER = bookingTable.headerCell;
|
||||
@@ -63,7 +66,7 @@ function CapacityCell({
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
export function WarehouseTable({ warehouses, onView, onEdit, onDelete }: WarehouseTableProps) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
);
|
||||
@@ -179,6 +182,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{onDelete ? (
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => onDelete(row.original)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Alert, Badge, Group, Stack, Text } from '@mantine/core';
|
||||
import { Info, MapPin } from 'lucide-react';
|
||||
|
||||
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
|
||||
import type { TrainLoadableItem } from '@/services/warehouse.service';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
/**
|
||||
* The train schedule's per-yard loading window, shown where the warehouse
|
||||
* actually loads. Cargo may only go onto a wagon inside a started window
|
||||
* (assertStationWorkStarted on the API side), so the same Start/End loading
|
||||
* controls the train schedule page carries belong here too — otherwise the
|
||||
* warehouse operator sees a Load button that the server refuses.
|
||||
*
|
||||
* One block per boarding yard of the items waiting to load, since a train can
|
||||
* pick cargo up at more than one stop and each stop has its own window.
|
||||
*/
|
||||
export function YardLoadingWindows({
|
||||
scheduleId,
|
||||
items,
|
||||
logs,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
items: TrainLoadableItem[];
|
||||
logs: Record<string, StationWorkLog> | null | undefined;
|
||||
}) {
|
||||
const yards = useMemo(() => {
|
||||
const byYard = new Map<string, { label: string; count: number }>();
|
||||
for (const item of items) {
|
||||
// Everything still queued for this train, not only the ready ones — the
|
||||
// operator starts the window before the cargo finishes becoming ready.
|
||||
if (item.status === 'LOADED' || !item.originYardId) continue;
|
||||
const entry = byYard.get(item.originYardId);
|
||||
if (entry) entry.count += 1;
|
||||
else byYard.set(item.originYardId, { label: item.originYardLabel ?? 'Boarding yard', count: 1 });
|
||||
}
|
||||
return [...byYard.entries()].map(([yardId, v]) => ({ yardId, ...v }));
|
||||
}, [items]);
|
||||
|
||||
if (yards.length === 0) return null;
|
||||
|
||||
const anyOpen = yards.some((y) => logs?.[y.yardId]?.loading?.startedAt);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Loading window
|
||||
</Text>
|
||||
{!anyOpen ? (
|
||||
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
||||
Start loading at the yard before loading cargo — the train schedule records the same
|
||||
window, and the server refuses cargo outside it.
|
||||
</Alert>
|
||||
) : null}
|
||||
{yards.map((yard) => (
|
||||
<Stack key={yard.yardId} gap={6} p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
||||
<Group gap={8} align="center">
|
||||
<MapPin size={13} />
|
||||
<Text size="xs" fw={700}>
|
||||
{yard.label}
|
||||
</Text>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{yard.count} queued
|
||||
</Badge>
|
||||
</Group>
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={yard.yardId}
|
||||
phase="loading"
|
||||
log={logs?.[yard.yardId]?.loading}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Badge, Group, Loader, Modal, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { formatDateTime, humanize } from '@/lib/format';
|
||||
import { api } from '@/services/api';
|
||||
import type { ZoneContentItem } from '@/types/warehouse';
|
||||
|
||||
/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */
|
||||
export interface ZoneRef {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface ZoneContentsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
zone: ZoneRef | null;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ZoneContentItem>[] = [
|
||||
{
|
||||
id: 'containerNumber',
|
||||
header: 'Container No.',
|
||||
// Bulk cargo has no container of its own — it still occupies the zone.
|
||||
cell: ({ row }) => row.original.containerNumber ?? 'Bulk cargo',
|
||||
},
|
||||
{
|
||||
id: 'unloadedAt',
|
||||
header: 'Unloaded',
|
||||
cell: ({ row }) => formatDateTime(row.original.unloadedAt),
|
||||
},
|
||||
{
|
||||
id: 'containerType',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => row.original.containerType ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'direction',
|
||||
header: 'Import / Export',
|
||||
cell: ({ row }) =>
|
||||
row.original.direction ? (
|
||||
<Badge color={row.original.direction === 'IMPORT' ? 'blue' : 'teal'} variant="light">
|
||||
{humanize(row.original.direction)}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'loadState',
|
||||
header: 'Full / Empty',
|
||||
cell: ({ row }) =>
|
||||
row.original.loadState ? (
|
||||
<Badge color={row.original.loadState === 'EMPTY' ? 'gray' : 'green'} variant="light">
|
||||
{humanize(row.original.loadState)}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bookingReference',
|
||||
header: 'Booking',
|
||||
cell: ({ row }) => row.original.bookingReference ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => humanize(row.original.status),
|
||||
},
|
||||
];
|
||||
|
||||
export function ZoneContentsModal({ opened, onClose, zone }: ZoneContentsModalProps) {
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.warehouses.zoneContents.queryOptions({
|
||||
input: { zoneId: zone?.id ?? '' },
|
||||
enabled: opened && Boolean(zone?.id),
|
||||
}),
|
||||
);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Text fw={700}>{zone ? `${zone.name} (${zone.code})` : 'Zone'}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{items.length} item(s) in this zone
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text c="red" ta="center" py="xl">
|
||||
Failed to load zone contents.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status="success"
|
||||
emptyMessage="This zone is empty."
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneContentsModal;
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Ban, CircleSlash, Layers, MoreVertical, Plus, Trash2, Unlock } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import type { SlotEffectiveStatus, ZoneLayoutSlot, ZoneLayoutStack } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { ZoneRef } from './ZoneContentsModal';
|
||||
|
||||
interface ZoneLayoutModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
zone: ZoneRef | null;
|
||||
}
|
||||
|
||||
/** One colour per slot state, used by both the cell and the legend. */
|
||||
const SLOT_TONE: Record<SlotEffectiveStatus, { color: string; label: string }> = {
|
||||
OCCUPIED: { color: 'blue', label: 'Occupied' },
|
||||
AVAILABLE: { color: 'teal', label: 'Free' },
|
||||
RESERVED: { color: 'orange', label: 'Reserved' },
|
||||
BLOCKED: { color: 'red', label: 'Blocked' },
|
||||
INACTIVE: { color: 'gray', label: 'Inactive' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A container stack seen from the side: level 3 on top, level 1 on the ground —
|
||||
* the order the API already returns and the order the yard actually looks.
|
||||
*/
|
||||
function SlotCell({
|
||||
slot,
|
||||
onSetStatus,
|
||||
canEdit,
|
||||
}: {
|
||||
slot: ZoneLayoutSlot;
|
||||
canEdit: boolean;
|
||||
onSetStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
}) {
|
||||
const tone = SLOT_TONE[slot.effectiveStatus];
|
||||
const occupied = slot.effectiveStatus === 'OCCUPIED';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="sm"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{ borderLeft: `4px solid var(--mantine-color-${tone.color}-6)` }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" fw={600} w={20}>
|
||||
L{slot.level}
|
||||
</Text>
|
||||
<Text size="sm" truncate title={slot.containerNumber ?? tone.label}>
|
||||
{occupied ? (slot.containerNumber ?? 'Container') : tone.label}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* An occupied level has no status to set — empty it by moving the box. */}
|
||||
{canEdit && !occupied ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" size="sm" aria-label={`Level ${slot.level} actions`}>
|
||||
<MoreVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Unlock size={14} />}
|
||||
disabled={slot.effectiveStatus === 'AVAILABLE'}
|
||||
onClick={() => onSetStatus(slot, 'AVAILABLE')}
|
||||
>
|
||||
Mark free
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CircleSlash size={14} />}
|
||||
disabled={slot.effectiveStatus === 'RESERVED'}
|
||||
onClick={() => onSetStatus(slot, 'RESERVED')}
|
||||
>
|
||||
Reserve
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ban size={14} />}
|
||||
color="red"
|
||||
disabled={slot.effectiveStatus === 'BLOCKED'}
|
||||
onClick={() => onSetStatus(slot, 'BLOCKED')}
|
||||
>
|
||||
Block
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function StackCard({
|
||||
stack,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onSetSlotStatus,
|
||||
onDelete,
|
||||
}: {
|
||||
stack: ZoneLayoutStack;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onSetSlotStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
onDelete: (stack: ZoneLayoutStack) => void;
|
||||
}) {
|
||||
const filled = stack.slots.filter((s) => s.effectiveStatus === 'OCCUPIED').length;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Stack gap={8}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate title={stack.name ?? stack.code}>
|
||||
{stack.code}
|
||||
</Text>
|
||||
{stack.status !== 'ACTIVE' || !stack.isActive ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={filled === stack.maxStackHeight ? 'blue' : 'gray'}>
|
||||
{filled}/{stack.maxStackHeight}
|
||||
</Badge>
|
||||
{canDelete ? (
|
||||
<Tooltip label="Delete stack">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDelete(stack)}
|
||||
aria-label={`Delete stack ${stack.code}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
{stack.slots.map((slot) => (
|
||||
<SlotCell key={slot.slotId} slot={slot} canEdit={canEdit} onSetStatus={onSetSlotStatus} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical layout of one zone — every ground stack with its levels, what stands
|
||||
* on each, and the capacity numbers that are routinely confused (configured vs
|
||||
* built vs full). Stacks are created and retired from here, since there is
|
||||
* nowhere else the yard layout is visible.
|
||||
*/
|
||||
export function ZoneLayoutModal({ opened, onClose, zone }: ZoneLayoutModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.warehouseZones.create);
|
||||
const canEdit = hasPermission(user, FREIGHT_PERMS.warehouseZones.update);
|
||||
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||
|
||||
const zoneId = zone?.id ?? '';
|
||||
const { data, isLoading, isError, refetch } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: opened && Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [height, setHeight] = useState<number>(3);
|
||||
|
||||
const createStack = useMutation(api.warehouses.createStack.mutationOptions());
|
||||
const deleteStack = useMutation(api.warehouses.deleteStack.mutationOptions());
|
||||
const updateSlot = useMutation(api.warehouses.updateSlot.mutationOptions());
|
||||
|
||||
const stacks = data?.stacks ?? [];
|
||||
const summary = data?.summary;
|
||||
|
||||
const nextCode = useMemo(() => {
|
||||
// Suggest the next number in the zone's own series (ZA-001 → ZA-002) so
|
||||
// codes stay sortable, which is the order the placement engine walks.
|
||||
const numbered = stacks
|
||||
.map((s) => /^(.*?)(\d+)$/.exec(s.code))
|
||||
.filter((m): m is RegExpExecArray => Boolean(m));
|
||||
if (numbered.length === 0) return '';
|
||||
const last = numbered[numbered.length - 1];
|
||||
const width = last[2].length;
|
||||
const next = Math.max(...numbered.map((m) => Number(m[2]))) + 1;
|
||||
return `${last[1]}${String(next).padStart(width, '0')}`;
|
||||
}, [stacks]);
|
||||
|
||||
const submitStack = () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
toast({ variant: 'destructive', title: 'Stack code is required' });
|
||||
return;
|
||||
}
|
||||
createStack.mutate(
|
||||
{ zoneId, payload: { code: trimmed, maxStackHeight: height } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Stack ${trimmed} created with ${height} level(s)` });
|
||||
setCode('');
|
||||
setCreating(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not create the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeStack = (stack: ZoneLayoutStack) => {
|
||||
if (!window.confirm(`Delete stack ${stack.code}? It must be empty first.`)) return;
|
||||
deleteStack.mutate(
|
||||
{ id: stack.stackId, zoneId },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Stack ${stack.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not delete the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setSlotStatus = (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => {
|
||||
updateSlot.mutate(
|
||||
{ slotId: slot.slotId, zoneId, payload: { status, isActive: true } },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Level ${slot.level} set to ${SLOT_TONE[status].label}` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not update the level',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Layers size={18} />
|
||||
<Text fw={700}>{zone ? `${zone.name} (${zone.code}) layout` : 'Zone layout'}</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{summary ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Stat label="Configured capacity" value={summary.configuredCapacity ?? '—'} />
|
||||
<Stat label="Slots built" value={summary.physicalSlotCount} />
|
||||
<Stat label="Occupied" value={summary.occupiedSlotCount} color="blue" />
|
||||
<Stat label="Free" value={summary.availableSlotCount} color="teal" />
|
||||
<Stat label="Reserved" value={summary.reservedSlotCount} color="orange" />
|
||||
<Stat label="Blocked" value={summary.blockedSlotCount} color="red" />
|
||||
</Group>
|
||||
{summary.inconsistent ? (
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{summary.physicalSlotCount} slots are built but the zone is configured for{' '}
|
||||
{summary.configuredCapacity}. Raise the zone capacity or remove stacks — the
|
||||
configured figure was left as it is.
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
{(Object.keys(SLOT_TONE) as SlotEffectiveStatus[]).map((key) => (
|
||||
<Badge key={key} size="xs" variant="light" color={SLOT_TONE[key].color}>
|
||||
{SLOT_TONE[key].label}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{canCreate ? (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
variant={creating ? 'default' : 'filled'}
|
||||
onClick={() => {
|
||||
setCreating((open) => !open);
|
||||
if (!creating && !code) setCode(nextCode);
|
||||
}}
|
||||
>
|
||||
{creating ? 'Cancel' : 'Add stack'}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{creating ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label="Stack code"
|
||||
placeholder="ZA-001"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Levels"
|
||||
description="One slot is created per level"
|
||||
min={1}
|
||||
max={10}
|
||||
value={height}
|
||||
onChange={(v) => setHeight(Number(v) || 1)}
|
||||
w={140}
|
||||
/>
|
||||
<Button onClick={submitStack} loading={createStack.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Stack align="center" py="xl" gap="xs">
|
||||
<Text c="red">Failed to load the zone layout.</Text>
|
||||
<Button variant="default" size="xs" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
) : stacks.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No ground stacks configured in this zone yet. Containers stored here keep zone-level
|
||||
placement until stacks exist.
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||
{stacks.map((stack) => (
|
||||
<StackCard
|
||||
key={stack.stackId}
|
||||
stack={stack}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onSetSlotStatus={setSlotStatus}
|
||||
onDelete={removeStack}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number | string; color?: string }) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneLayoutModal;
|
||||
@@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string {
|
||||
interface ZoneOccupancyHeatmapProps {
|
||||
/** Scope to one yard; omit for all zones. */
|
||||
yardId?: string;
|
||||
/** Pass to make each tile open that zone; omitted leaves the tiles inert. */
|
||||
onZoneClick?: (zone: ZoneOccupancy) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
|
||||
* container-count based (unit-consistent); weight is shown as context only.
|
||||
*/
|
||||
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||
export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) {
|
||||
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||
const t = tone(z.occupancyPct);
|
||||
const pct = z.occupancyPct ?? 0;
|
||||
return (
|
||||
<Card key={z.id} withBorder radius="md" padding="sm">
|
||||
<Card
|
||||
key={z.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
onClick={onZoneClick ? () => onZoneClick(z) : undefined}
|
||||
style={onZoneClick ? { cursor: 'pointer' } : undefined}
|
||||
title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text fw={600} size="sm" truncate title={z.name}>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseContainerReturnExcel } from "./container-return-excel";
|
||||
|
||||
/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
|
||||
function sheetFile(aoa: unknown[][]): File {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
|
||||
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
||||
return new File([buf], "returns.xlsx");
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
|
||||
describe("parseContainerReturnExcel", () => {
|
||||
it("parses a good sheet, normalizing size and returned-by", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
["Yard tally — August"], // title row above the header is ignored
|
||||
HEADERS,
|
||||
["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
|
||||
["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
|
||||
expect(result.rows[0].containerSize).toBe("40");
|
||||
expect(result.rows[0].returnedBy).toBe("EDR");
|
||||
expect(result.rows[0].companyName).toBe("Acme PLC");
|
||||
expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
|
||||
expect(result.rows[1].containerSize).toBe("20");
|
||||
expect(result.rows[1].returnedBy).toBe("CUSTOMER");
|
||||
});
|
||||
|
||||
it("rejects the whole file when a container number is invalid", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors[0]).toContain("Row 2");
|
||||
});
|
||||
|
||||
it("rejects duplicate container numbers", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
HEADERS,
|
||||
["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when there is no container-number column", async () => {
|
||||
const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
|
||||
expect(result.errors[0]).toContain("Container Number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for empties already sitting in an EDR yard that were never
|
||||
// entered in the system. One spreadsheet row per container. All-or-nothing —
|
||||
// any bad row rejects the whole file with row-numbered errors, so a partial
|
||||
// backfill can never silently drop boxes.
|
||||
|
||||
// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ParsedReturnRow {
|
||||
containerNumber: string;
|
||||
containerSize: "20" | "40" | null;
|
||||
companyName: string;
|
||||
returnedBy: "EDR" | "CUSTOMER" | null;
|
||||
returnDate: string | null;
|
||||
facility: string;
|
||||
yard: string;
|
||||
zone: string;
|
||||
condition: string;
|
||||
handoverNote: string;
|
||||
}
|
||||
|
||||
export interface ContainerReturnExcelResult {
|
||||
rows: ParsedReturnRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerNumber"
|
||||
| "containerSize"
|
||||
| "companyName"
|
||||
| "returnedBy"
|
||||
| "returnDate"
|
||||
| "facility"
|
||||
| "yard"
|
||||
| "zone"
|
||||
| "condition"
|
||||
| "handoverNote";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size") || h.includes("type")) return "containerSize";
|
||||
if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
|
||||
if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
|
||||
if (h.includes("date")) return "returnDate";
|
||||
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
|
||||
if (h.includes("yard")) return "yard";
|
||||
if (h.includes("zone")) return "zone";
|
||||
if (h.includes("condition") || h.includes("damage")) return "condition";
|
||||
if (h.includes("note") || h.includes("remark")) return "handoverNote";
|
||||
// Least specific last, so "Container Size" is not eaten by "container".
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
|
||||
function normalizeSize(raw: string): "20" | "40" | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (digits.startsWith("20")) return "20";
|
||||
if (digits.startsWith("40") || digits.startsWith("45")) return "40";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "EDR", "EDR last mile", "customer", "self haul" … */
|
||||
function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
|
||||
const v = raw.toLowerCase();
|
||||
if (!v.trim()) return null;
|
||||
if (v.includes("edr")) return "EDR";
|
||||
if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel dates arrive either as a serial number (raw cells) or as text. Returns
|
||||
* an ISO instant, or null when the cell is empty/unparseable.
|
||||
*/
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const v = raw.trim();
|
||||
if (!v) return null;
|
||||
// Excel serial: days since 1899-12-30.
|
||||
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
|
||||
const serial = Number(v);
|
||||
if (serial > 20000 && serial < 80000) {
|
||||
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = new Date(v);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per empty container. Returns either
|
||||
* the full row set or the list of row-numbered problems — never both.
|
||||
*/
|
||||
export async function parseContainerReturnExcel(file: File): Promise<ContainerReturnExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
|
||||
|
||||
// First row carrying a container-number column is the header; titles and
|
||||
// blank rows above it are ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find a "Container Number" column — download the template to see the expected format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedReturnRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sizeRaw = cell("containerSize");
|
||||
const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
|
||||
if (sizeRaw && !containerSize) {
|
||||
errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
|
||||
}
|
||||
|
||||
const returnedByRaw = cell("returnedBy");
|
||||
const returnedBy = normalizeReturnedBy(returnedByRaw);
|
||||
if (returnedByRaw && !returnedBy) {
|
||||
errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
|
||||
}
|
||||
|
||||
const dateRaw = cell("returnDate");
|
||||
const returnDate = normalizeDate(dateRaw);
|
||||
if (dateRaw && !returnDate) {
|
||||
errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerNumber,
|
||||
containerSize,
|
||||
companyName: cell("companyName"),
|
||||
returnedBy,
|
||||
returnDate,
|
||||
facility: cell("facility"),
|
||||
yard: cell("yard"),
|
||||
zone: cell("zone"),
|
||||
condition: cell("condition"),
|
||||
handoverNote: cell("handoverNote"),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) {
|
||||
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Download the import template with one filled sample row. */
|
||||
export function downloadContainerReturnTemplate() {
|
||||
const headers = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
const sample = [
|
||||
"TEMU1234567",
|
||||
"40",
|
||||
"Acme Import PLC",
|
||||
"CUSTOMER",
|
||||
new Date().toISOString().split("T")[0],
|
||||
"Gelan Multipurpose port",
|
||||
"Yard A",
|
||||
"Zone 1",
|
||||
"Sound",
|
||||
"Backfilled from yard tally sheet",
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
|
||||
XLSX.writeFile(workbook, "container-return-import-template.xlsx");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseFullContainerExcel } from "./full-container-excel";
|
||||
|
||||
function sheetFile(aoa: unknown[][]): File {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
|
||||
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
||||
return new File([buf], "backlog.xlsx");
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Arrival Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Seal Number",
|
||||
"Weight (Tons)",
|
||||
"Notes",
|
||||
];
|
||||
|
||||
const row = (num: string, arrived: string, weight: string | number = 24.5) => [
|
||||
num,
|
||||
"40",
|
||||
"Acme PLC",
|
||||
arrived,
|
||||
"Gelan",
|
||||
"A",
|
||||
"1",
|
||||
"SL1",
|
||||
weight,
|
||||
"",
|
||||
];
|
||||
|
||||
describe("parseFullContainerExcel", () => {
|
||||
it("parses a backlog sheet with past arrival dates", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("temu1234567", "2026-03-14"), row("MSCU7654321", "2025-11-02")]),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
|
||||
expect(result.rows[0].arrivedAt?.startsWith("2026-03-14")).toBe(true);
|
||||
expect(result.rows[0].companyName).toBe("Acme PLC");
|
||||
});
|
||||
|
||||
it("rejects a future arrival date — a backlog box arrived in the past", async () => {
|
||||
const future = new Date();
|
||||
future.setFullYear(future.getFullYear() + 1);
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("TEMU1234567", future.toISOString().slice(0, 10))]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("in the future"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an arrival date of today", async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const result = await parseFullContainerExcel(sheetFile([HEADERS, row("TEMU1234567", today)]));
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid container number and a negative weight", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("NOPE", "2026-03-14"), row("MSCU7654321", "2026-03-14", -3)]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("ISO container number"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("0 or more"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate container numbers", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("TEMU1234567", "2026-03-14"), row("temu1234567", "2026-03-15")]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for loaded containers already sitting in a yard but never
|
||||
// entered in the system. One row per container. All-or-nothing — any bad row
|
||||
// rejects the file with row-numbered errors, so a half-registered yard cannot
|
||||
// happen.
|
||||
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ParsedFullContainerRow {
|
||||
containerNumber: string;
|
||||
containerSize: string;
|
||||
companyName: string;
|
||||
/** ISO instant; null when the cell was empty or unreadable. */
|
||||
arrivedAt: string | null;
|
||||
facility: string;
|
||||
yard: string;
|
||||
zone: string;
|
||||
sealNumber: string;
|
||||
weight: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FullContainerExcelResult {
|
||||
rows: ParsedFullContainerRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerNumber"
|
||||
| "containerSize"
|
||||
| "companyName"
|
||||
| "arrivedAt"
|
||||
| "facility"
|
||||
| "yard"
|
||||
| "zone"
|
||||
| "sealNumber"
|
||||
| "weight"
|
||||
| "notes";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("seal")) return "sealNumber";
|
||||
if (h.includes("size") || h.includes("type")) return "containerSize";
|
||||
if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
|
||||
if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
|
||||
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
|
||||
if (h.includes("yard")) return "yard";
|
||||
if (h.includes("zone")) return "zone";
|
||||
if (h.includes("weight") || h.includes("vgm")) return "weight";
|
||||
if (h.includes("note") || h.includes("remark")) return "notes";
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel dates arrive either as a serial number (raw cells) or as text.
|
||||
* Returns an ISO instant, or null when the cell is empty/unparseable.
|
||||
*/
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const v = raw.trim();
|
||||
if (!v) return null;
|
||||
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
|
||||
const serial = Number(v);
|
||||
if (serial > 20000 && serial < 80000) {
|
||||
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = new Date(v);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/** Parse an uploaded workbook into one row per loaded container. */
|
||||
export async function parseFullContainerExcel(file: File): Promise<FullContainerExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
|
||||
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find a "Container Number" column — download the template to see the expected format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedFullContainerRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
const startOfTomorrow = new Date();
|
||||
startOfTomorrow.setHours(24, 0, 0, 0);
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const arrivedRaw = cell("arrivedAt");
|
||||
const arrivedAt = normalizeDate(arrivedRaw);
|
||||
if (arrivedRaw && !arrivedAt) {
|
||||
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
|
||||
}
|
||||
// The whole point of a backlog is that it arrived in the past.
|
||||
if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
|
||||
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
|
||||
}
|
||||
|
||||
const weightRaw = cell("weight");
|
||||
if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
|
||||
errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerNumber,
|
||||
containerSize: cell("containerSize"),
|
||||
companyName: cell("companyName"),
|
||||
arrivedAt,
|
||||
facility: cell("facility"),
|
||||
yard: cell("yard"),
|
||||
zone: cell("zone"),
|
||||
sealNumber: cell("sealNumber"),
|
||||
weight: weightRaw,
|
||||
notes: cell("notes"),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) {
|
||||
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Download the import template with one filled sample row. */
|
||||
export function downloadFullContainerTemplate() {
|
||||
const headers = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Arrival Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Seal Number",
|
||||
"Weight (Tons)",
|
||||
"Notes",
|
||||
];
|
||||
const sample = [
|
||||
"TEMU1234567",
|
||||
"40",
|
||||
"Acme Import PLC",
|
||||
"2026-03-14",
|
||||
"Gelan Multipurpose port",
|
||||
"Yard A",
|
||||
"Zone 1",
|
||||
"SL482910",
|
||||
24.5,
|
||||
"Backlog — registered from yard tally sheet",
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
|
||||
XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
|
||||
export { ZoneLayoutModal } from './ZoneLayoutModal';
|
||||
export { SlotPicker } from './SlotPicker';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
WAREHOUSE_FREIGHT_TYPES,
|
||||
WAREHOUSE_TYPES,
|
||||
WAREHOUSE_YARD_TYPES,
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
@@ -73,6 +74,7 @@ export const yardsForBooking = (
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const warehouseFreightTypeOptions = toOptions(WAREHOUSE_FREIGHT_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
export const statusOptions = toOptions(WAREHOUSE_STATUSES);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fillableLevels } from './SlotPicker';
|
||||
import type { SlotEffectiveStatus, ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
/**
|
||||
* The picker must offer only positions the API will accept, or the operator
|
||||
* gets a 400 for a level the form itself suggested.
|
||||
*/
|
||||
function layout(
|
||||
stacks: Array<{
|
||||
code: string;
|
||||
height?: number;
|
||||
isActive?: boolean;
|
||||
levels: SlotEffectiveStatus[];
|
||||
}>,
|
||||
): ZoneLayout {
|
||||
return {
|
||||
zoneId: 'zone-1',
|
||||
zoneCode: 'L1-O-A-ZA',
|
||||
zoneName: 'Zone A',
|
||||
summary: {
|
||||
configuredCapacity: 60,
|
||||
physicalSlotCount: 60,
|
||||
occupiedSlotCount: 0,
|
||||
reservedSlotCount: 0,
|
||||
blockedSlotCount: 0,
|
||||
inactiveSlotCount: 0,
|
||||
availableSlotCount: 60,
|
||||
inconsistent: false,
|
||||
},
|
||||
stacks: stacks.map((stack) => ({
|
||||
stackId: `id-${stack.code}`,
|
||||
code: stack.code,
|
||||
name: null,
|
||||
maxStackHeight: stack.height ?? 3,
|
||||
status: stack.isActive === false ? 'INACTIVE' : 'ACTIVE',
|
||||
isActive: stack.isActive !== false,
|
||||
// The API returns the highest level first — mirror that here.
|
||||
slots: stack.levels
|
||||
.map((effectiveStatus, index) => ({
|
||||
slotId: `${stack.code}-L${index + 1}`,
|
||||
level: index + 1,
|
||||
effectiveStatus,
|
||||
inventoryId: effectiveStatus === 'OCCUPIED' ? `inv-${stack.code}-${index + 1}` : null,
|
||||
containerNumber: null,
|
||||
}))
|
||||
.reverse(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SlotPicker.fillableLevels', () => {
|
||||
it('offers the ground level of an empty stack', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L1', label: 'ZA-001 — level 1 (ground)' }]);
|
||||
});
|
||||
|
||||
it('offers only the level directly above the top container', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L2', label: 'ZA-001 — level 2 (on 1 container)' }]);
|
||||
});
|
||||
|
||||
it('never offers a level that would float over an empty one', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'AVAILABLE'] }]));
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L3']);
|
||||
});
|
||||
|
||||
it('drops a full stack', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'OCCUPIED'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a stack whose next level is blocked or reserved', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['BLOCKED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
expect(fillableLevels(layout([{ code: 'ZA-002', levels: ['RESERVED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an inactive stack', () => {
|
||||
expect(
|
||||
fillableLevels(layout([{ code: 'ZA-001', isActive: false, levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }])),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists one position per stack across the zone', () => {
|
||||
const options = fillableLevels(
|
||||
layout([
|
||||
{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] },
|
||||
{ code: 'ZA-002', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] },
|
||||
]),
|
||||
);
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L2', 'ZA-002-L1']);
|
||||
});
|
||||
|
||||
it('returns nothing before the layout has loaded', () => {
|
||||
expect(fillableLevels(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
/**
|
||||
* Registered customer companies, as Autocomplete options. The picker is an
|
||||
* Autocomplete rather than a Select on purpose: a company that is not on the
|
||||
* system yet is typed in, and only the name is kept.
|
||||
*/
|
||||
export function useCompanyOptions() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["companies-autocomplete"],
|
||||
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
|
||||
// Company names are not unique — Mantine throws on duplicate option values,
|
||||
// so the list is deduped by the trimmed name.
|
||||
const names = [...new Set(companies.map((c) => c.name.trim()).filter(Boolean))];
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
names,
|
||||
/**
|
||||
* Name → company id, only when exactly one company carries that name. An
|
||||
* ambiguous name resolves to nothing rather than to an arbitrary company:
|
||||
* the container keeps the typed name and no wrong customer is attached.
|
||||
*/
|
||||
resolveId: (name: string): string | undefined => {
|
||||
const key = name.trim().toLowerCase();
|
||||
const matches = companies.filter((c) => c.name.trim().toLowerCase() === key);
|
||||
return matches.length === 1 ? matches[0].id : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
|
||||
resetTarget: (id: string) =>
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
@@ -160,6 +161,8 @@ export const QUERY_KEYS = {
|
||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
marshallingStops: (id: string) =>
|
||||
["train-scheduling", "marshalling-stops", id] as const,
|
||||
batchBoard: (filters?: unknown) =>
|
||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
|
||||
@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
|
||||
`/backoffice/customers/${companyId}/reset-password`,
|
||||
RESET_TARGET: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-target`,
|
||||
ACCOUNTS: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/accounts`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
@@ -536,6 +538,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
MARSHALLING_STOPS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/stops`,
|
||||
MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
@@ -651,6 +657,16 @@ export const URL_CONSTANTS = {
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: "/warehouse-zones",
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
LAYOUT: (id: string) => `/warehouse-zones/${id}/layout`,
|
||||
SLOT_SUMMARY: (id: string) => `/warehouse-zones/${id}/slot-summary`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONE_STACKS: {
|
||||
BASE: "/warehouse-zone-stacks",
|
||||
BY_ZONE: (zoneId: string) => `/warehouse-zone-stacks?zoneId=${zoneId}`,
|
||||
BY_ID: (id: string) => `/warehouse-zone-stacks/${id}`,
|
||||
OCCUPANCY: (id: string) => `/warehouse-zone-stacks/${id}/occupancy`,
|
||||
SLOT: (slotId: string) => `/warehouse-zone-stacks/slots/${slotId}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
@@ -665,6 +681,10 @@ export const URL_CONSTANTS = {
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
FIND_SLOT: "/warehouse-inventory/placement/find-slot",
|
||||
ASSIGN_SLOT: (id: string) => `/warehouse-inventory/${id}/assign-slot`,
|
||||
RELEASE_SLOT: (id: string) => `/warehouse-inventory/${id}/release-slot`,
|
||||
ACCESSIBILITY: (id: string) => `/warehouse-inventory/${id}/accessibility`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
@@ -706,6 +726,8 @@ export const URL_CONSTANTS = {
|
||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||
: `/warehouse-inventory/eligible-bookings`,
|
||||
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
||||
REGISTER_BACKLOG: "/warehouse-inventory/register-backlog",
|
||||
REGISTER_BACKLOG_BULK: "/warehouse-inventory/register-backlog-bulk",
|
||||
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
||||
@@ -778,6 +800,15 @@ export const URL_CONSTANTS = {
|
||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||
},
|
||||
|
||||
EMPTY_RETURN_REQUESTS: {
|
||||
BASE: "/empty-return-requests",
|
||||
PLANNED: "/empty-return-requests/planned",
|
||||
ELIGIBILITY: (bookingId: string) => `/empty-return-requests/eligibility/${bookingId}`,
|
||||
BY_BOOKING: (bookingId: string) => `/empty-return-requests/by-booking/${bookingId}`,
|
||||
APPROVE: (id: string) => `/empty-return-requests/${id}/approve`,
|
||||
REJECT: (id: string) => `/empty-return-requests/${id}/reject`,
|
||||
},
|
||||
|
||||
IMPORT_OPERATIONS: {
|
||||
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
|
||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||
@@ -794,6 +825,8 @@ export const URL_CONSTANTS = {
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||
EMPTY_RETURN_BOOKINGS: "/import-operations/empty-return-bookings",
|
||||
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
|
||||
|
||||
@@ -82,6 +82,14 @@ export function useUpdateWarehouse() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWarehouse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => warehouseService.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseYards(warehouseId?: string) {
|
||||
|
||||
@@ -60,3 +60,10 @@ export function formatBytes(bytes: number): string {
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DDTHH:mm` for now, in local time — what `datetime-local` expects. */
|
||||
export function localNowForInput(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:warehouse_zones:view",
|
||||
create: "edr_freight_app:warehouse_zones:create",
|
||||
update: "edr_freight_app:warehouse_zones:update",
|
||||
delete: "edr_freight_app:warehouse_zones:delete",
|
||||
},
|
||||
warehouseAllocationRules: {
|
||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||
@@ -357,6 +358,10 @@ export const FREIGHT_PERMS = {
|
||||
send: "edr_freight_app:additional_charges:send",
|
||||
cancel: "edr_freight_app:additional_charges:cancel",
|
||||
},
|
||||
emptyReturnRequests: {
|
||||
view: "edr_freight_app:empty_return_requests:view",
|
||||
review: "edr_freight_app:empty_return_requests:review",
|
||||
},
|
||||
/**
|
||||
* Audit trail. View-only — the API exposes no write routes for audit rows,
|
||||
* so there is no manage/delete counterpart to grant.
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
@@ -375,11 +376,9 @@ export default function BookingRequestDetailPage() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
// Blob response: the JSON reason is inside the Blob, so
|
||||
// the sync path would show only "status code 400".
|
||||
toast.error(await extractDownloadErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -140,6 +140,25 @@ export default function BookingRequestsPage() {
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Content options mirror the booking wizard's cargo picker: the group itself
|
||||
// — which the server expands to every commodity beneath it — then each
|
||||
// commodity, labelled by its full path so a generically-named leaf still
|
||||
// reads unambiguously. A group with no descendants is emitted by the
|
||||
// reference-data tree as its own single child; drop that duplicate.
|
||||
const cargoTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.cargo_type ?? []).flatMap((group) => [
|
||||
{ value: group.id, label: group.name },
|
||||
...(group.children ?? [])
|
||||
.filter((child) => child.id !== group.id)
|
||||
.map((child) => ({
|
||||
value: child.id,
|
||||
label: `${group.name} → ${child.name}`,
|
||||
})),
|
||||
]),
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||
// the header's document-review alarm opens exactly the undecided requests
|
||||
// it is counting down for. No sync effect needed any more: controls.values
|
||||
@@ -147,6 +166,15 @@ export default function BookingRequestsPage() {
|
||||
// already mounted just works, and every filter — direction included —
|
||||
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
// Container types, flattened out of the reference data's size groups.
|
||||
const containerTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.containers ?? []).flatMap((group) =>
|
||||
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
|
||||
),
|
||||
[refData],
|
||||
);
|
||||
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -183,6 +211,65 @@ export default function BookingRequestsPage() {
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
// Cargo group or commodity. The group row matches its whole subtree
|
||||
// server-side, so "Bulk" returns every bulk commodity under it.
|
||||
key: "cargoTypeId",
|
||||
label: "Content",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: cargoTypeOptions,
|
||||
},
|
||||
{
|
||||
// Containers carry no customer-written description, so this is also how
|
||||
// they are reached: it matches container types ("40FT") as well as the
|
||||
// commodity name and the bulk cargo description.
|
||||
key: "cargoText",
|
||||
label: "Content contains",
|
||||
type: "text",
|
||||
secondary: true,
|
||||
placeholder: "Commodity, description or container type",
|
||||
},
|
||||
{
|
||||
key: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: containerTypeOptions,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
// Counts boxes. Scoped to the container-type filter when one is set, so
|
||||
// this one control answers "10 containers" and "10 forty-footers" both.
|
||||
key: "containers",
|
||||
label: "Containers",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["is", "between"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { containersMin: v.v[0], containersMax: v.v[1] }
|
||||
: { containersMin: v.v[0], containersMax: v.v[0] },
|
||||
},
|
||||
{
|
||||
// Declared on the shipment request, not yet on the booking. Pair it
|
||||
// with Containers = 0 to find the set awaiting completion.
|
||||
key: "requestedContainers",
|
||||
label: "Requested containers",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["is", "between"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? {
|
||||
requestedContainersMin: v.v[0],
|
||||
requestedContainersMax: v.v[1],
|
||||
}
|
||||
: {
|
||||
requestedContainersMin: v.v[0],
|
||||
requestedContainersMax: v.v[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
@@ -244,7 +331,13 @@ export default function BookingRequestsPage() {
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
],
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
[
|
||||
filterOptions,
|
||||
yardOptions,
|
||||
serviceTypeOptions,
|
||||
cargoTypeOptions,
|
||||
containerTypeOptions,
|
||||
],
|
||||
);
|
||||
|
||||
const controls = useFilters(bookingFilterDefs, {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
KeyRound,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const accountsQuery = useQuery(
|
||||
api.customers.accounts.queryOptions({
|
||||
input: { companyId: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const documentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
{/* The profile reference (EX-A00001). Minted only when a reviewer
|
||||
approves the role, so an unapproved one has none — say so
|
||||
rather than rendering an empty line that reads as a bug. */}
|
||||
{row.original.reference ? (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Ref. issued on approval
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "etradeBusiness",
|
||||
header: "eTrade business",
|
||||
cell: ({ row }) => {
|
||||
const business = row.original.etradeBusiness;
|
||||
// Not attached is a review finding, not a blank: the role names no
|
||||
// business, so there is nothing to check the uploaded licence
|
||||
// against. Companies with no eTrade record legitimately show this,
|
||||
// which is why it reads as a warning rather than an error.
|
||||
if (!business) {
|
||||
return (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Not attached
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2} maw={230}>
|
||||
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
|
||||
{business.tradeName || "(no trade name on this licence)"}
|
||||
</Text>
|
||||
{business.activity && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{business.activity}
|
||||
</Text>
|
||||
)}
|
||||
{/* The licence number is what the reviewer matches against the
|
||||
uploaded document — trade names repeat across licences. */}
|
||||
<Text size="xs" c="dimmed">
|
||||
{business.licenceNumber}
|
||||
</Text>
|
||||
{business.renewedTo && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Renewed to {business.renewedTo}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
|
||||
Account
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
{/* Padding sits on the header section, not the card, so the
|
||||
table runs edge to edge. minWidth carries the eTrade
|
||||
business column; the region scrolls rather than squashing
|
||||
the other columns. */}
|
||||
<TableCard
|
||||
minWidth={980}
|
||||
header={
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={profiles} />
|
||||
</Group>
|
||||
{/* Narrower than the old full-width layout — the table
|
||||
shares the row with the people column now. */}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={760}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</TableCard>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
|
||||
contact details on Overview: those are business contact info on the
|
||||
company row, these are the credentials someone actually signs in
|
||||
with, and the two drift apart routinely. Cards rather than a table:
|
||||
it is a handful of rows of mostly-optional detail, which a table
|
||||
renders as a field of dashes. */}
|
||||
<Tabs.Panel value="accounts" pt="lg">
|
||||
{accountsQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
) : accountsQuery.isError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Failed to load accounts"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">
|
||||
We couldn't load this customer's portal logins.
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => void accountsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : (accountsQuery.data?.length ?? 0) === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
This customer has no portal login yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{accountsQuery.data?.map((account) => (
|
||||
<AccountCard key={account.profileId} account={account} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
// The operational role, not `type` above: one `customer` company routinely
|
||||
// holds importer AND exporter, so this asks "who does X?" rather than
|
||||
// "what kind of company is this?".
|
||||
key: "profileType",
|
||||
label: "Role",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: (
|
||||
[
|
||||
"importer",
|
||||
"exporter",
|
||||
"freight_forwarder",
|
||||
"dj_freight_forwarder",
|
||||
"transporter",
|
||||
] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: "Sector",
|
||||
@@ -348,7 +366,7 @@ export default function CustomersPage() {
|
||||
<FilterBar
|
||||
defs={CUSTOMER_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
|
||||
@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoicePaymentMethod,
|
||||
invoiceTypeLabel,
|
||||
paymentMethodLabel,
|
||||
type Invoice,
|
||||
type InvoiceListFilter,
|
||||
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
|
||||
{ key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Currency",
|
||||
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
|
||||
size: 220,
|
||||
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1200}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -7,15 +7,19 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
ExternalLink,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -25,18 +29,134 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
isoToLocalDateStr,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoiceTypeLabel,
|
||||
type InvoiceListFilter,
|
||||
type OfflineUsdInvoice,
|
||||
} from "@/types/invoice";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still
|
||||
* needs settling" cut this worklist applies when no status pill is set. Only
|
||||
* the export needs it spelled out (see `exportParams`); the list gets it from
|
||||
* the server.
|
||||
*/
|
||||
const OPEN_STATUSES = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PaymentProcessing,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* The same filter vocabulary the invoices list uses, minus `currency` — this
|
||||
* panel is mounted once per currency and pins it from the prop, so offering it
|
||||
* as a pill could only contradict the tab you are on.
|
||||
*/
|
||||
const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
// Computed from the balance and due date rather than read off `status` —
|
||||
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
|
||||
key: "settlement",
|
||||
label: "Settlement",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "outstanding", label: "Outstanding" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
],
|
||||
toParams: (v) =>
|
||||
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
|
||||
},
|
||||
{
|
||||
key: "issued",
|
||||
label: "Issued",
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("issuedFrom", "issuedTo"),
|
||||
},
|
||||
{
|
||||
key: "sources",
|
||||
label: "Source",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: SOURCE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
label: "Type",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: INVOICE_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "paymentMethods",
|
||||
label: "Payment method",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: PAYMENT_METHOD_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "due",
|
||||
label: "Due",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("dueFrom", "dueTo"),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["between", "is"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { minAmount: v.v[0], maxAmount: v.v[1] }
|
||||
: { minAmount: v.v[0], maxAmount: v.v[0] },
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "issuedAt:DESC", label: "Newest issued" },
|
||||
{ value: "issuedAt:ASC", label: "Oldest issued" },
|
||||
{ value: "dueAt:ASC", label: "Due soonest" },
|
||||
{ value: "totalAmount:DESC", label: "Largest amount" },
|
||||
{ value: "balanceAmount:DESC", label: "Largest balance" },
|
||||
{ value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" },
|
||||
];
|
||||
|
||||
/** Date params the export's `daterange` coercion expects as calendar days. */
|
||||
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
|
||||
|
||||
/**
|
||||
* The customer's pay window, counted down live. Finance must confirm the bank
|
||||
@@ -157,21 +277,19 @@ export default function UsdPaymentsPanel({
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
// as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys.
|
||||
const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, {
|
||||
defaultSort: "issuedAt:DESC",
|
||||
pageSize: 10,
|
||||
ns: "mp",
|
||||
});
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
|
||||
const { user } = useAuth();
|
||||
const canConfirm = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
@@ -184,20 +302,8 @@ export default function UsdPaymentsPanel({
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
currency,
|
||||
],
|
||||
() => ({ ...controls.params, currency }) as unknown as InvoiceListFilter,
|
||||
[controls.params, currency],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
@@ -209,7 +315,24 @@ export default function UsdPaymentsPanel({
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const outstanding = data?.outstanding?.[currency] ?? 0;
|
||||
|
||||
/**
|
||||
* The export's `daterange` filters are coerced from calendar days while the
|
||||
* list takes ISO instants, so each bound is handed over as the local day it
|
||||
* falls on. The worklist's implicit "still open" cut is not a URL param
|
||||
* either — spelled out here so an exported file covers the rows the screen
|
||||
* shows rather than every invoice ever raised in this currency.
|
||||
*/
|
||||
const exportParams = useMemo(() => {
|
||||
const out: Record<string, unknown> = { ...controls.params, currency };
|
||||
for (const key of EXPORT_DAY_KEYS) {
|
||||
if (typeof out[key] === "string")
|
||||
out[key] = isoToLocalDateStr(out[key] as string);
|
||||
}
|
||||
if (!out.statuses) out.statuses = OPEN_STATUSES.join(",");
|
||||
return out;
|
||||
}, [controls.params, currency]);
|
||||
|
||||
const closeConfirm = () => {
|
||||
setConfirming(null);
|
||||
@@ -310,6 +433,15 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -340,7 +472,9 @@ export default function UsdPaymentsPanel({
|
||||
header: "Pay window",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
|
||||
<PayWindowCell
|
||||
deadline={row.original.booking?.paymentDeadline ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -358,47 +492,40 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: `Outstanding in ${currency}`,
|
||||
hint: "all matching",
|
||||
value: formatMoney(outstanding, currency),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Invoices listed",
|
||||
value: total,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
<FilterBar
|
||||
defs={MANUAL_PAYMENT_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId={`manual-payments-${currency.toLowerCase()}`}
|
||||
>
|
||||
<ExportButton
|
||||
datasetKey="invoices"
|
||||
params={exportParams}
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "open"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "open" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "Awaiting payment", value: "open" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -410,11 +537,11 @@ export default function UsdPaymentsPanel({
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1160}>
|
||||
<Box miw={1320}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -423,8 +550,8 @@ export default function UsdPaymentsPanel({
|
||||
emptyMessage={
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: controls.activeCount > 0
|
||||
? "No invoices match these filters."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
@@ -435,18 +562,7 @@ export default function UsdPaymentsPanel({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
@@ -458,9 +574,7 @@ export default function UsdPaymentsPanel({
|
||||
<Modal
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
title={<Text fw={700}>Confirm manual payment</Text>}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
@@ -510,6 +624,6 @@ export default function UsdPaymentsPanel({
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Route,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
// Marshalling 2: the current on-board list, reprinted after station work.
|
||||
// Marshalling: the on-board list, reprinted after station work. Numbered
|
||||
// per corridor stop that actually coupled/uncoupled something (Marshalling
|
||||
// 2, 3, 4…) — falls back to the single "current position" doc when nothing
|
||||
// has happened yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled:
|
||||
Boolean(scheduleId) &&
|
||||
["DISPATCHED", "ARRIVED"].includes(trackQuery.data?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
const intercityMarshalling = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
mutationFn: (stopIndex?: number) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
|
||||
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
});
|
||||
const openIntercityMarshalling = async () => {
|
||||
const openIntercityMarshalling = async (stopIndex?: number) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await intercityMarshalling.mutateAsync();
|
||||
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
||||
const blob = await intercityMarshalling.mutateAsync(stopIndex);
|
||||
const filename =
|
||||
stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Intercity marshalling ready",
|
||||
title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{inTransit || arrived ? (
|
||||
{(inTransit || arrived) && marshallingStops.length === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
{(inTransit || arrived) && marshallingStops.length > 0 ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={15} color={T.brand} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
>
|
||||
Marshalling
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
onClick={() => void openIntercityMarshalling(stop.stopIndex)}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Two-column work surface ── */}
|
||||
|
||||
@@ -285,13 +285,34 @@ export default function TrainScheduleV2DetailPage() {
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
||||
variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
mutationFn: ({
|
||||
id,
|
||||
direction,
|
||||
variant,
|
||||
stopIndex,
|
||||
}: {
|
||||
id: string;
|
||||
direction?: string | null;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
|
||||
: variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
// Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
|
||||
// that actually coupled/uncoupled something. Empty when nothing has yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
@@ -640,6 +661,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
@@ -647,13 +669,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
variant: options?.variant,
|
||||
stopIndex: options?.stopIndex,
|
||||
});
|
||||
const prefix =
|
||||
options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
options?.stopIndex != null
|
||||
? `marshalling-${options.stopIndex}`
|
||||
: options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
@@ -1326,7 +1351,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Marshalling PDF
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
@@ -1340,6 +1365,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Intercity Marshalling
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{/* One item per corridor stop that actually coupled/uncoupled
|
||||
something (Marshalling 2, 3, 4…) — replaces the single
|
||||
"current position" item once anything has happened. */}
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: `Marshalling ${stop.stopIndex} ready`,
|
||||
successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
|
||||
stopIndex: stop.stopIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
|
||||
@@ -18,15 +18,20 @@ import {
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
Autocomplete,
|
||||
Input,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, FileText, History, Upload } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
|
||||
import { downloadContainerReturnTemplate } from "@/components/warehouses/container-return-excel";
|
||||
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
@@ -35,13 +40,16 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
|
||||
import type {
|
||||
EmptyContainerReturn,
|
||||
EmptyContainerReturnStatus,
|
||||
EmptyContainerSize,
|
||||
EmptyReturnBooking,
|
||||
PlannedEmptyReturn,
|
||||
} from "@/types/importOperations";
|
||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { formatDateTime, localNowForInput } from "@/lib/format";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
@@ -72,6 +80,32 @@ const RETURNED_BY_SERIES = [
|
||||
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A scheduled request seen as the booking shape `BookingEmptyReturnModal`
|
||||
* takes, so confirming an arrival runs through exactly the same recording
|
||||
* path as any other empty return.
|
||||
*/
|
||||
const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({
|
||||
bookingId: planned.bookingId,
|
||||
bookingReference: planned.bookingReference ?? planned.bookingId,
|
||||
bookingStatus: "SCHEDULED_RETURN",
|
||||
equipmentReturn: "REQUESTED",
|
||||
customerId: planned.companyId,
|
||||
companyName: planned.companyName,
|
||||
containers: planned.containers.map((container) => ({
|
||||
key: `${planned.requestId}-${container.containerNumber}`,
|
||||
unitId: `${planned.requestId}-${container.containerNumber}`,
|
||||
containerNumber: container.containerNumber,
|
||||
containerSize: null,
|
||||
containerType: null,
|
||||
returnId: container.returnId,
|
||||
returnStatus: null,
|
||||
})),
|
||||
expectedCount: planned.containers.length,
|
||||
recordedCount: planned.containers.filter((c) => c.returnId).length,
|
||||
pendingCount: planned.containers.filter((c) => !c.returnId).length,
|
||||
});
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
@@ -102,9 +136,13 @@ export default function ContainerReturnsPage() {
|
||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [historyRow, setHistoryRow] = useState<any | null>(null);
|
||||
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
|
||||
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
|
||||
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
|
||||
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
|
||||
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
|
||||
|
||||
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
|
||||
@@ -140,6 +178,24 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Bookings that ship WITH empty-container return and still owe empties. This
|
||||
// list stands on the booking's own return flags, so it does not wait for the
|
||||
// box to reach a warehouse or for a last-mile truck to be assigned — the
|
||||
// queue below still covers that path.
|
||||
const emptyReturnBookingsQuery = useQuery({
|
||||
queryKey: ["empty-return-bookings"],
|
||||
queryFn: () => importOperationsService.listEmptyReturnBookings(),
|
||||
});
|
||||
const emptyReturnBookings = emptyReturnBookingsQuery.data ?? [];
|
||||
|
||||
// Requests the customer already paid for and booked a truck against — the
|
||||
// warehouse confirms these on arrival, which is what records the containers.
|
||||
const plannedReturnsQuery = useQuery({
|
||||
queryKey: ["planned-empty-returns"],
|
||||
queryFn: () => emptyReturnRequestsService.planned(),
|
||||
});
|
||||
const plannedReturns = plannedReturnsQuery.data ?? [];
|
||||
|
||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
||||
const containerReturnsQuery = useQuery({
|
||||
queryKey: ["container-returns", bookingIds],
|
||||
@@ -281,17 +337,24 @@ export default function ContainerReturnsPage() {
|
||||
searchKeys: ["bookingRef", "companyName"],
|
||||
});
|
||||
|
||||
const bookingReturnControls = useListControls(emptyReturnBookings, {
|
||||
searchKeys: ["bookingReference", "companyName", "bookingStatus"],
|
||||
});
|
||||
|
||||
const createReturnsMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
trucks: Array<{
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
companyName?: string;
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
containerSize?: EmptyContainerSize;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
yard?: string;
|
||||
zone?: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
}>;
|
||||
@@ -306,7 +369,10 @@ export default function ContainerReturnsPage() {
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
companyName: truck.companyName,
|
||||
facility: container.warehouse,
|
||||
yard: container.yard,
|
||||
zone: container.zone,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
returnedBy: truck.returnType,
|
||||
@@ -319,8 +385,14 @@ export default function ContainerReturnsPage() {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-bookings"] });
|
||||
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
|
||||
setReturnModalOpen(false);
|
||||
setStandaloneModalOpen(false);
|
||||
setActiveKey(null);
|
||||
setEmptyReturnBooking(null);
|
||||
setArrivingReturn(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -372,9 +444,15 @@ export default function ContainerReturnsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bookingRef",
|
||||
header: "Booking Ref",
|
||||
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||
id: "company",
|
||||
header: "Company",
|
||||
// One identity column: who the box belongs to, and the booking it came
|
||||
// back on. A standalone return has no booking, so only the name shows.
|
||||
cell: ({ row }) => {
|
||||
const { companyName, bookingReference } = row.original;
|
||||
if (!companyName) return bookingReference || "—";
|
||||
return bookingReference ? `${companyName} (${bookingReference})` : companyName;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "returnedBy",
|
||||
@@ -390,9 +468,8 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
{
|
||||
id: "returnDate",
|
||||
header: "Returned Date",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
|
||||
header: "Returned Date & Time",
|
||||
cell: ({ row }) => formatDateTime(row.original.returnDate),
|
||||
},
|
||||
{
|
||||
id: "facility",
|
||||
@@ -510,11 +587,259 @@ export default function ContainerReturnsPage() {
|
||||
{ label: "Customer Self-Haul", value: "customer" },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>
|
||||
Record Return
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadContainerReturnTemplate()}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>Record Return</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{plannedReturns.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>Planned Empty Returns</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customers who paid for an empty return and booked a truck. Confirm the arrival
|
||||
to record the containers.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" size="lg" color="orange">
|
||||
{plannedReturns.length} expected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Return Date</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{plannedReturns.map((planned) => {
|
||||
const outstanding = planned.containers.filter((c) => !c.returnId);
|
||||
return (
|
||||
<Table.Tr key={planned.requestId}>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{planned.truckDriverName ?? "—"}
|
||||
{planned.truckType ? ` · ${planned.truckType}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge color={outstanding.length ? "orange" : "edr-green"}>
|
||||
{outstanding.length} of {planned.containers.length} outstanding
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{planned.containers.map((c) => c.containerNumber).join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={outstanding.length === 0}
|
||||
onClick={() => setArrivingReturn(planned)}
|
||||
>
|
||||
Confirm Arrival
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>Bookings With Empty Container Return</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Bookings that ship with equipment return and still owe empties. Open one to pick
|
||||
the containers coming back, then record the return for that booking.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" size="lg">
|
||||
{emptyReturnBookings.length} booking{emptyReturnBookings.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={bookingReturnControls.search}
|
||||
onSearchChange={bookingReturnControls.setSearch}
|
||||
searchPlaceholder="Search booking, company, status…"
|
||||
dateFrom={bookingReturnControls.dateFrom}
|
||||
onDateFromChange={bookingReturnControls.setDateFrom}
|
||||
dateTo={bookingReturnControls.dateTo}
|
||||
onDateToChange={bookingReturnControls.setDateTo}
|
||||
showDateRange={false}
|
||||
hasFilters={bookingReturnControls.hasFilters}
|
||||
onReset={bookingReturnControls.reset}
|
||||
/>
|
||||
|
||||
{emptyReturnBookingsQuery.isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : emptyReturnBookingsQuery.isError ? (
|
||||
<Alert color="red">
|
||||
Could not load bookings with empty container return.{" "}
|
||||
{extractErrorMessage(emptyReturnBookingsQuery.error)}
|
||||
</Alert>
|
||||
) : bookingReturnControls.pagedRows.length === 0 ? (
|
||||
<Alert color="gray">
|
||||
No booking is waiting on an empty container return.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Booking Status</Table.Th>
|
||||
<Table.Th>Containers To Return</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookingReturnControls.pagedRows.map((booking) => {
|
||||
const isOpen = expandedBooking === booking.bookingId;
|
||||
return (
|
||||
<Fragment key={booking.bookingId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() =>
|
||||
setExpandedBooking(isOpen ? null : booking.bookingId)
|
||||
}
|
||||
title={isOpen ? "Hide containers" : "Show containers"}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{booking.bookingReference}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{booking.bookingStatus.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Badge color="orange">{booking.pendingCount} pending</Badge>
|
||||
{booking.recordedCount > 0 && (
|
||||
<Badge color="edr-green" variant="light">
|
||||
{booking.recordedCount} recorded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setEmptyReturnBooking(booking)}
|
||||
>
|
||||
Empty Container Return
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Return Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.containers.map((container) => (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>{container.containerNumber}</Table.Td>
|
||||
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
|
||||
<Table.Td>{container.containerType ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{container.returnStatus ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
{RETURN_STATUS_LABEL[container.returnStatus] ??
|
||||
container.returnStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="orange" variant="light">
|
||||
Awaiting return
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={bookingReturnControls.pagination}
|
||||
pageCount={bookingReturnControls.pageCount}
|
||||
totalCount={bookingReturnControls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={bookingReturnControls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
@@ -688,6 +1013,37 @@ export default function ContainerReturnsPage() {
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
<BookingEmptyReturnModal
|
||||
booking={emptyReturnBooking}
|
||||
onClose={() => setEmptyReturnBooking(null)}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* A scheduled return arrives on the customer's own truck, so the modal
|
||||
opens pre-set to self-haul with that truck already noted. */}
|
||||
<BookingEmptyReturnModal
|
||||
title="Confirm Empty Return Arrival"
|
||||
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
|
||||
onClose={() => setArrivingReturn(null)}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
defaultReturnedBy="CUSTOMER"
|
||||
defaultHandoverNote={
|
||||
arrivingReturn
|
||||
? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${
|
||||
arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<BulkContainerReturnModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
|
||||
/>
|
||||
|
||||
<ExportTrainAllocationModal
|
||||
row={allocateRow}
|
||||
onClose={() => setAllocateRow(null)}
|
||||
@@ -848,7 +1204,7 @@ interface ContainerReturnModalProps {
|
||||
|
||||
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
|
||||
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
@@ -940,13 +1296,20 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
@@ -982,6 +1345,305 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface BookingEmptyReturnModalProps {
|
||||
booking: EmptyReturnBooking | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
/** Pre-set for a scheduled return, where the truck type is already known. */
|
||||
defaultReturnedBy?: "EDR" | "CUSTOMER";
|
||||
/** Pre-set for a scheduled return — the truck the customer told us about. */
|
||||
defaultHandoverNote?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the empty return for ONE booking: tick the containers coming back,
|
||||
* say where they landed, and every tick becomes an empty container return on
|
||||
* that booking. Containers whose return is already recorded stay visible but
|
||||
* cannot be ticked again. A legacy booking that never captured container
|
||||
* numbers shows numberless slots — the number is typed here instead.
|
||||
*/
|
||||
function BookingEmptyReturnModal({
|
||||
booking,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
defaultReturnedBy,
|
||||
defaultHandoverNote,
|
||||
title = "Empty Container Return",
|
||||
}: BookingEmptyReturnModalProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
const bookingId = booking?.bookingId ?? null;
|
||||
|
||||
// A fresh booking starts from a clean form — never inherit the last one's
|
||||
// ticks, typed numbers, or placement.
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
setReturnedBy(defaultReturnedBy ?? null);
|
||||
setReturnDate(localNowForInput());
|
||||
setWarehouse(null);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
setCondition("");
|
||||
setHandoverNote(defaultHandoverNote ?? "");
|
||||
}, [bookingId, defaultReturnedBy, defaultHandoverNote]);
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
return await warehouseService.list({});
|
||||
},
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouse]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoneId(null);
|
||||
}, [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh: any) => ({
|
||||
value: wh.id,
|
||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const pending = (booking?.containers ?? []).filter((container) => !container.returnId);
|
||||
|
||||
const toggle = (key: string, checked: boolean) =>
|
||||
setSelected((current) => (checked ? [...current, key] : current.filter((k) => k !== key)));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!booking || !selected.length || !warehouse || !returnedBy) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses)
|
||||
? warehouses.find((wh: any) => wh.id === warehouse)
|
||||
: null;
|
||||
const selectedYard = yards?.find((y) => y.id === yardId);
|
||||
const selectedZone = zones?.find((z) => z.id === zoneId);
|
||||
|
||||
const containers = pending
|
||||
.filter((container) => selected.includes(container.key))
|
||||
.map((container) => ({
|
||||
containerNumber: container.containerNumber,
|
||||
// The booking records "20ft"/"40ft"; the wagon rule only needs the number.
|
||||
containerSize: container.containerSize?.includes("40")
|
||||
? ("40" as const)
|
||||
: container.containerSize?.includes("20")
|
||||
? ("20" as const)
|
||||
: undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
zone: selectedZone?.name,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
}));
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
{
|
||||
bookingId: booking.bookingId,
|
||||
customerId: booking.customerId,
|
||||
companyName: booking.companyName ?? undefined,
|
||||
returnType: returnedBy,
|
||||
containers,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
|
||||
{booking && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{booking.bookingReference}</Text>
|
||||
{booking.companyName && <Text c="dimmed">{booking.companyName}</Text>}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking.bookingStatus.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Containers to return
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === pending.length ? [] : pending.map((c) => c.key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.length === pending.length ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th w={90}>Size</Table.Th>
|
||||
<Table.Th w={150}>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.containers.map((container) => {
|
||||
const recorded = Boolean(container.returnId);
|
||||
return (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.includes(container.key)}
|
||||
disabled={recorded}
|
||||
onChange={(e) => toggle(container.key, e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{container.containerNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{recorded ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
{(container.returnStatus &&
|
||||
RETURN_STATUS_LABEL[container.returnStatus]) ??
|
||||
"Recorded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="orange" variant="light">
|
||||
Awaiting return
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Last Mile" },
|
||||
{ value: "CUSTOMER", label: "Customer Self-Haul" },
|
||||
]}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
value={warehouse}
|
||||
onChange={setWarehouse}
|
||||
data={warehouseOptions}
|
||||
required
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouse}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
placeholder="Damage, residue, or cleanliness notes"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Handover Note"
|
||||
placeholder="Consignee, trucker, or authorization notes"
|
||||
value={handoverNote}
|
||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selected.length || !warehouse || !returnedBy}
|
||||
loading={loading}
|
||||
>
|
||||
Record Empty Return ({selected.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface StandaloneReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -991,9 +1653,10 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [company, setCompany] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
@@ -1009,6 +1672,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
|
||||
const companies = useCompanyOptions();
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
@@ -1047,7 +1711,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
trucks: [
|
||||
{
|
||||
bookingId: null,
|
||||
customerId: null,
|
||||
customerId: company ? (companies.resolveId(company) ?? null) : null,
|
||||
companyName: company || undefined,
|
||||
returnType: returnedBy,
|
||||
containers: [
|
||||
{
|
||||
@@ -1066,9 +1731,10 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setCompany("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setReturnDate(localNowForInput());
|
||||
setWarehouse(null);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
@@ -1092,6 +1758,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
@@ -1145,13 +1821,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import { extractErrorMessage } from "@/components/warehouses/options";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
|
||||
import type {
|
||||
EmptyReturnRequest,
|
||||
EmptyReturnRequestStatus,
|
||||
} from "@/types/importOperations";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
|
||||
SUBMITTED: { label: "Awaiting review", color: "orange" },
|
||||
APPROVED: { label: "Awaiting payment", color: "yellow" },
|
||||
REJECTED: { label: "Rejected", color: "red" },
|
||||
PAID: { label: "Paid — awaiting date", color: "blue" },
|
||||
SCHEDULED: { label: "Scheduled", color: "edr-green" },
|
||||
COMPLETED: { label: "Returned", color: "gray" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
const money = (amount: number | null | undefined, currency: string | null | undefined) =>
|
||||
amount == null
|
||||
? "—"
|
||||
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim();
|
||||
|
||||
/**
|
||||
* The queue for customer-initiated empty container returns: a booking sold
|
||||
* WITHOUT the return service, whose customer now wants to send the empties
|
||||
* back. Staff price and approve — which invoices the customer — or reject with
|
||||
* a reason. Everything after payment (date, truck) happens in the portal, and
|
||||
* the containers themselves are recorded on Container Returns.
|
||||
*/
|
||||
export default function EmptyReturnRequestsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
// Anyone who runs container returns can watch the queue; pricing and
|
||||
// approving is its own permission, so show the buttons disabled rather than
|
||||
// letting them fire into a 403.
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
|
||||
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["empty-return-requests"],
|
||||
queryFn: () => emptyReturnRequestsService.list(),
|
||||
});
|
||||
|
||||
const requests = useMemo(() => {
|
||||
const rows = requestsQuery.data ?? [];
|
||||
return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows;
|
||||
}, [requestsQuery.data, statusFilter]);
|
||||
|
||||
const controls = useListControls(requests, {
|
||||
dateKey: "submittedAt",
|
||||
searchValue: (row) =>
|
||||
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-requests"] });
|
||||
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
|
||||
};
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) =>
|
||||
emptyReturnRequestsService.approve(id, { unitAmount }),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Approved — invoice sent to the customer" });
|
||||
invalidate();
|
||||
setApproving(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not approve the request",
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
emptyReturnRequestsService.reject(id, reason),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Request rejected" });
|
||||
invalidate();
|
||||
setRejecting(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not reject the request",
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<EmptyReturnRequest>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.bookingReference ?? row.original.bookingId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.companyName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
header: "Containers",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm">{row.original.containerCount}</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "submittedAt",
|
||||
header: "Requested",
|
||||
cell: ({ row }) => formatDateTime(row.original.submittedAt),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
cell: ({ row }) =>
|
||||
row.original.quotedTotalAmount == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{money(row.original.quotedTotalAmount, row.original.currency)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "return",
|
||||
header: "Return",
|
||||
cell: ({ row }) =>
|
||||
row.original.requestedReturnDate ? (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{row.original.requestedReturnDate}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.truckPlateNumber ?? "—"}
|
||||
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
|
||||
{meta?.label ?? row.original.status}
|
||||
</Badge>
|
||||
{row.original.rejectionReason && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.rejectionReason}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) =>
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setRejecting(row.original)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setApproving(row.original)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Empty Return Requests"
|
||||
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
|
||||
/>
|
||||
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>
|
||||
Requests
|
||||
{pending > 0 && (
|
||||
<Badge ml="sm" color="orange" variant="light">
|
||||
{pending} awaiting review
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search booking, company, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Requested"
|
||||
hasFilters={controls.hasFilters || Boolean(statusFilter)}
|
||||
onReset={() => {
|
||||
controls.reset();
|
||||
setStatusFilter(null);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
data={Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}))}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</ListControls>
|
||||
|
||||
{requestsQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : requestsQuery.isError ? (
|
||||
<Alert color="red">
|
||||
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
|
||||
</Alert>
|
||||
) : controls.pagedRows.length === 0 ? (
|
||||
<Alert color="gray">No empty return requests.</Alert>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<ApproveModal
|
||||
request={approving}
|
||||
onClose={() => setApproving(null)}
|
||||
onApprove={(unitAmount) =>
|
||||
approving && approveMutation.mutate({ id: approving.id, unitAmount })
|
||||
}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={!!rejecting}
|
||||
onClose={() => setRejecting(null)}
|
||||
title="Reject empty return request"
|
||||
size="md"
|
||||
>
|
||||
{rejecting && (
|
||||
<RejectForm
|
||||
request={rejecting}
|
||||
loading={rejectMutation.isPending}
|
||||
onCancel={() => setRejecting(null)}
|
||||
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pricing step. The per-container price is prefilled from the booking's
|
||||
* route WITH_RETURN rate; the reviewer can override it before approving, and
|
||||
* approving is what issues the customer's invoice.
|
||||
*/
|
||||
function ApproveModal({
|
||||
request,
|
||||
onClose,
|
||||
onApprove,
|
||||
loading,
|
||||
}: {
|
||||
request: EmptyReturnRequest | null;
|
||||
onClose: () => void;
|
||||
onApprove: (unitAmount?: number) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [unitAmount, setUnitAmount] = useState<number | "">("");
|
||||
|
||||
const quoteQuery = useQuery({
|
||||
queryKey: ["empty-return-quote", request?.bookingId],
|
||||
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
|
||||
enabled: Boolean(request),
|
||||
});
|
||||
|
||||
// Prefill from the route rate as soon as it lands, and start clean whenever
|
||||
// a different request is opened.
|
||||
useEffect(() => {
|
||||
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
|
||||
}, [quoteQuery.data?.unitAmount, request?.id]);
|
||||
|
||||
const count = request?.containerCount ?? 0;
|
||||
const total = typeof unitAmount === "number" ? unitAmount * count : null;
|
||||
const currency = quoteQuery.data?.currency ?? "ETB";
|
||||
|
||||
return (
|
||||
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
|
||||
{request && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
|
||||
<Text c="dimmed">{request.companyName ?? "—"}</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
Containers coming back
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{request.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{quoteQuery.isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
{quoteQuery.data?.unavailableReason && (
|
||||
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={`Price per container (${currency})`}
|
||||
description={
|
||||
quoteQuery.data?.sourceRateUsd
|
||||
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
|
||||
: "No route rate found — enter the amount to bill."
|
||||
}
|
||||
value={unitAmount}
|
||||
onChange={(value) =>
|
||||
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
|
||||
}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
required
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{count} container{count === 1 ? "" : "s"} ×{" "}
|
||||
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
|
||||
</Text>
|
||||
<Text size="lg" fw={700} ta="right">
|
||||
{total == null
|
||||
? "—"
|
||||
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Approving issues this invoice to the customer. They pay it in the portal, then
|
||||
choose the return date and give the truck details.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
|
||||
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
|
||||
loading={loading}
|
||||
>
|
||||
Approve & invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectForm({
|
||||
request,
|
||||
loading,
|
||||
onCancel,
|
||||
onReject,
|
||||
}: {
|
||||
request: EmptyReturnRequest;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onReject: (reason: string) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{request.bookingReference ?? request.bookingId} — {request.containerCount} container
|
||||
{request.containerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
description="Shown to the customer."
|
||||
placeholder="Why this return cannot be accepted"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.currentTarget.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Input,
|
||||
List,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
|
||||
import {
|
||||
downloadFullContainerTemplate,
|
||||
parseFullContainerExcel,
|
||||
type ParsedFullContainerRow,
|
||||
} from "@/components/warehouses/full-container-excel";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { containerTypesService } from "@/services/container-types.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { RegisterBacklogContainerPayload } from "@/types/warehouse";
|
||||
|
||||
/** `YYYY-MM-DD` for today — the latest arrival a backlog box can claim. */
|
||||
function todayForInput(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded containers that have been sitting in a yard since before the system
|
||||
* knew about them. Registering one records its true arrival date without
|
||||
* billing storage for the history — the server flags the row so the fee engine
|
||||
* skips it entirely.
|
||||
*/
|
||||
export default function RegisterFullContainersPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const companies = useCompanyOptions();
|
||||
const [mode, setMode] = useState<"single" | "bulk">("single");
|
||||
|
||||
// Location + owner, shared by both modes. In bulk they are the defaults that
|
||||
// fill any blank cell in the sheet.
|
||||
const [company, setCompany] = useState("");
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [arrivedAt, setArrivedAt] = useState(todayForInput());
|
||||
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
|
||||
|
||||
// Single-container fields.
|
||||
const [containerNumber, setContainerNumber] = useState("");
|
||||
const [sealNumber, setSealNumber] = useState("");
|
||||
const [weight, setWeight] = useState<number | string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Bulk fields.
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [rows, setRows] = useState<ParsedFullContainerRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: () => warehouseService.list({}),
|
||||
});
|
||||
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
|
||||
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
const { data: containerTypes = [] } = useQuery({
|
||||
queryKey: ["container-types-active"],
|
||||
queryFn: () => containerTypesService.getContainerTypes(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouseId]);
|
||||
useEffect(() => setZoneId(null), [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
|
||||
: [];
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
const containerTypeOptions = (containerTypes as any[]).map((ct) => ({
|
||||
value: ct.id,
|
||||
label: ct.label ? `${ct.label} (${ct.code})` : ct.code,
|
||||
}));
|
||||
|
||||
const locationReady = Boolean(warehouseId && yardId && zoneId);
|
||||
|
||||
const basePayload = useMemo(
|
||||
() => ({
|
||||
warehouseId: warehouseId ?? "",
|
||||
yardId: yardId ?? "",
|
||||
zoneId: zoneId ?? "",
|
||||
companyId: company ? companies.resolveId(company) : undefined,
|
||||
companyName: company || undefined,
|
||||
}),
|
||||
[warehouseId, yardId, zoneId, company, companies],
|
||||
);
|
||||
|
||||
const onSaved = (count: number) => {
|
||||
toast({ title: `${count} container${count === 1 ? "" : "s"} registered` });
|
||||
qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
|
||||
};
|
||||
|
||||
const singleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
warehouseService.registerBacklogContainer({
|
||||
...basePayload,
|
||||
containerNumber: containerNumber.trim().toUpperCase(),
|
||||
containerTypeId: containerTypeId ?? "",
|
||||
arrivedAt: new Date(arrivedAt).toISOString(),
|
||||
sealNumber: sealNumber.trim() || undefined,
|
||||
weight: weight === "" ? undefined : Number(weight),
|
||||
notes: notes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSaved(1);
|
||||
setContainerNumber("");
|
||||
setSealNumber("");
|
||||
setWeight("");
|
||||
setNotes("");
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not register container",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
}),
|
||||
});
|
||||
|
||||
// Row cell wins; the fields above the file fill the blanks.
|
||||
const toPayload = (row: ParsedFullContainerRow): RegisterBacklogContainerPayload => ({
|
||||
...basePayload,
|
||||
companyName: row.companyName || basePayload.companyName,
|
||||
companyId: row.companyName ? companies.resolveId(row.companyName) : basePayload.companyId,
|
||||
containerNumber: row.containerNumber,
|
||||
containerTypeId: containerTypeId ?? "",
|
||||
arrivedAt: row.arrivedAt ?? new Date(arrivedAt).toISOString(),
|
||||
sealNumber: row.sealNumber || undefined,
|
||||
weight: row.weight === "" ? undefined : Number(row.weight),
|
||||
notes: row.notes || undefined,
|
||||
});
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: () => warehouseService.registerBacklogContainersBulk(rows.map(toPayload)),
|
||||
onSuccess: (response) => {
|
||||
onSaved(response.data?.length ?? rows.length);
|
||||
setFile(null);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Bulk registration failed",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
}),
|
||||
});
|
||||
|
||||
const handleFile = async (next: File | null) => {
|
||||
setFile(next);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
if (!next) return;
|
||||
const result = await parseFullContainerExcel(next);
|
||||
setRows(result.rows);
|
||||
setParseErrors(result.errors);
|
||||
};
|
||||
|
||||
const singleReady =
|
||||
locationReady && Boolean(containerTypeId) && containerNumber.trim().length > 0 && Boolean(arrivedAt);
|
||||
const bulkReady = locationReady && Boolean(containerTypeId) && rows.length > 0;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Register Full Containers"
|
||||
subtitle="Loaded containers already in the yard but not yet on the system"
|
||||
/>
|
||||
|
||||
<Group mb="lg" justify="space-between">
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "single" | "bulk")}
|
||||
data={[
|
||||
{ label: "Single Container", value: "single" },
|
||||
{ label: "Bulk Upload", value: "bulk" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadFullContainerTemplate()}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Alert color="blue" mb="lg" title="Backlog registrations are not billed">
|
||||
The arrival date you enter is kept as the real record of how long the box has been here,
|
||||
but no storage or demurrage accrues against it.
|
||||
</Alert>
|
||||
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
<Select
|
||||
label="Container Type"
|
||||
placeholder="Select container type"
|
||||
value={containerTypeId}
|
||||
onChange={setContainerTypeId}
|
||||
data={containerTypeOptions}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Select warehouse"
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
data={warehouseOptions}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouseId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Input.Wrapper
|
||||
label="Arrival Date"
|
||||
description={
|
||||
mode === "bulk"
|
||||
? "Used for any row whose sheet cell is blank"
|
||||
: "When the container actually arrived in the yard"
|
||||
}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
value={arrivedAt}
|
||||
max={todayForInput()}
|
||||
onChange={(e) => setArrivedAt(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
{mode === "single" ? (
|
||||
<>
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="Container Number"
|
||||
placeholder="e.g., TEMU1234567"
|
||||
value={containerNumber}
|
||||
onChange={(e) => setContainerNumber(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal Number"
|
||||
placeholder="Optional"
|
||||
value={sealNumber}
|
||||
onChange={(e) => setSealNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Weight (Tons)"
|
||||
placeholder="Optional"
|
||||
value={weight}
|
||||
onChange={setWeight}
|
||||
min={0}
|
||||
decimalScale={3}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Where it came from, condition, anything worth recording"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => singleMutation.mutate()}
|
||||
disabled={!singleReady}
|
||||
loading={singleMutation.isPending}
|
||||
>
|
||||
Register Container
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input.Wrapper label="Excel file" description="One row per container">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
|
||||
style={{ display: "block", padding: "8px 0" }}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was registered`}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<List size="sm">
|
||||
{parseErrors.map((err) => (
|
||||
<List.Item key={err}>{err}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Preview
|
||||
</Text>
|
||||
<Badge size="sm">{rows.length} containers</Badge>
|
||||
{file && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{file.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th>Seal</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const payload = toPayload(row);
|
||||
return (
|
||||
<Table.Tr key={row.containerNumber}>
|
||||
<Table.Td>{payload.containerNumber}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">{payload.companyName || "—"}</Text>
|
||||
{payload.companyName && !payload.companyId && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(payload.arrivedAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.sealNumber ?? "—"}</Table.Td>
|
||||
<Table.Td>{payload.weight ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => bulkMutation.mutate()}
|
||||
disabled={!bulkReady}
|
||||
loading={bulkMutation.isPending}
|
||||
>
|
||||
Register {rows.length > 0 ? `${rows.length} containers` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Boxes, Layers, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -23,16 +23,27 @@ import {
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
ZoneContentsModal,
|
||||
type ZoneRef,
|
||||
ZoneLayoutModal,
|
||||
ZoneOccupancyHeatmap,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canDeleteYard = hasPermission(user, FREIGHT_PERMS.warehouseYards.delete);
|
||||
const canDeleteZone = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||
|
||||
const { data: warehouse, isLoading } = useQuery(
|
||||
api.warehouses.getById.queryOptions({
|
||||
@@ -52,6 +63,8 @@ export default function WarehouseDetailPage() {
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
|
||||
const [layoutZone, setLayoutZone] = useState<ZoneRef | null>(null);
|
||||
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
@@ -72,6 +85,44 @@ export default function WarehouseDetailPage() {
|
||||
[yards],
|
||||
);
|
||||
|
||||
const deleteYard = useMutation(api.warehouses.deleteYard.mutationOptions());
|
||||
const deleteZone = useMutation(api.warehouses.deleteZone.mutationOptions());
|
||||
|
||||
// The API refuses a yard that still has zones (and a zone that still holds
|
||||
// inventory) with a 409 — surface that message rather than a bare failure.
|
||||
const removeYard = useCallback(
|
||||
(yard: WarehouseYard) => {
|
||||
if (!window.confirm(`Delete yard ${yard.code}? Its zones must be removed first.`)) return;
|
||||
deleteYard.mutate(
|
||||
{ id: yard.id },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Yard ${yard.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
},
|
||||
);
|
||||
},
|
||||
[deleteYard, toast],
|
||||
);
|
||||
|
||||
const removeZone = useCallback(
|
||||
(zone: WarehouseZone) => {
|
||||
if (!window.confirm(`Delete zone ${zone.code}? It must be empty first.`)) return;
|
||||
deleteZone.mutate(
|
||||
{ id: zone.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Zone ${zone.code} deleted` });
|
||||
void zonesQuery.refetch();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
},
|
||||
);
|
||||
},
|
||||
[deleteZone, toast, zonesQuery],
|
||||
);
|
||||
|
||||
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
|
||||
() => [
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
@@ -98,20 +149,33 @@ export default function WarehouseDetailPage() {
|
||||
header: 'Actions',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{canDeleteYard ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete"
|
||||
onClick={() => removeYard(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[canDeleteYard, removeYard],
|
||||
);
|
||||
|
||||
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
|
||||
@@ -140,20 +204,41 @@ export default function WarehouseDetailPage() {
|
||||
header: 'Actions',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Stack layout"
|
||||
onClick={() => setLayoutZone(row.original)}
|
||||
>
|
||||
<Layers size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{canDeleteZone ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete"
|
||||
onClick={() => removeZone(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[canDeleteZone, removeZone],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -299,7 +384,10 @@ export default function WarehouseDetailPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
|
||||
<ZoneOccupancyHeatmap
|
||||
yardId={selectedYardId ?? undefined}
|
||||
onZoneClick={(zone) => setContentsZone(zone)}
|
||||
/>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
@@ -309,6 +397,7 @@ export default function WarehouseDetailPage() {
|
||||
<DataTable
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
onRowClick={(zone) => setContentsZone(zone)}
|
||||
status={
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
@@ -346,6 +435,18 @@ export default function WarehouseDetailPage() {
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
<ZoneLayoutModal
|
||||
opened={Boolean(layoutZone)}
|
||||
onClose={() => setLayoutZone(null)}
|
||||
zone={layoutZone}
|
||||
/>
|
||||
|
||||
<ZoneContentsModal
|
||||
opened={Boolean(contentsZone)}
|
||||
onClose={() => setContentsZone(null)}
|
||||
zone={contentsZone}
|
||||
/>
|
||||
|
||||
{selectedYardId && (
|
||||
<CreateZoneModal
|
||||
opened={zoneModalOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
CreateWarehouseModal,
|
||||
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useDeleteWarehouse, useWarehouses } from '@/hooks/useWarehouses';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteWarehouse();
|
||||
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouses.delete);
|
||||
const [filter, setFilter] = useState<WarehouseFilter>({});
|
||||
const [view, setView] = useState<WarehouseView>('table');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
|
||||
const handleDelete = (warehouse: Warehouse) => {
|
||||
if (!window.confirm(`Delete warehouse ${warehouse.code}? Yards must be removed first.`)) return;
|
||||
remove.mutate(warehouse.id, {
|
||||
onSuccess: () => toast({ title: `Warehouse ${warehouse.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
});
|
||||
};
|
||||
const onDelete = canDelete ? handleDelete : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
|
||||
) : (
|
||||
<>
|
||||
{view === 'table' ? (
|
||||
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
<WarehouseTable
|
||||
warehouses={controls.pagedRows}
|
||||
onView={openDetail}
|
||||
onEdit={openEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
<WarehouseCardView
|
||||
warehouses={controls.pagedRows}
|
||||
onView={openDetail}
|
||||
onEdit={openEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/shared/common/ui/tooltip";
|
||||
import { PositionName } from "../dto/delegation/delegationDto";
|
||||
import {
|
||||
getPositionCookie,
|
||||
setPositionCookie,
|
||||
} from "@/shared/utils/positionCookie";
|
||||
interface BasePosition {
|
||||
id: string;
|
||||
employeePositionId: string;
|
||||
@@ -55,7 +59,7 @@ export const PositionSelect = () => {
|
||||
? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? [])
|
||||
: [];
|
||||
const selectablePositions = allPositions ?? [];
|
||||
const currentPositionCookie = Cookies.get("current-position-id");
|
||||
const currentPositionCookie = getPositionCookie();
|
||||
const delegatedPositionCookie = Cookies.get("delegatedPositionId");
|
||||
const activePositionId =
|
||||
selectedPositionId || currentPositionCookie || delegatedPositionCookie;
|
||||
@@ -85,15 +89,18 @@ export const PositionSelect = () => {
|
||||
// (useAuthUser already self-heals this cookie for the same reason.)
|
||||
if (
|
||||
currentPosition.employeePositionId &&
|
||||
Cookies.get("current-position-id") !== currentPosition.employeePositionId
|
||||
getPositionCookie() !== currentPosition.employeePositionId
|
||||
) {
|
||||
Cookies.set("current-position-id", currentPosition.employeePositionId);
|
||||
setPositionCookie(currentPosition.employeePositionId);
|
||||
}
|
||||
|
||||
applyDelegationCookie(currentPosition);
|
||||
}, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]);
|
||||
|
||||
if (isLoading || selectablePositions.length === 0) return null;
|
||||
// Below two desks there is nothing to switch between. This now sits in the
|
||||
// main freight header, so a one-option dropdown would show for every
|
||||
// single-desk staff member.
|
||||
if (isLoading || selectablePositions.length < 2) return null;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const selected = selectablePositions.find((pos) => pos.id === value);
|
||||
@@ -102,7 +109,7 @@ export const PositionSelect = () => {
|
||||
// dropdown's own value is position.id, which the API does not match on.
|
||||
setSelectedPositionId(selected?.employeePositionId ?? value);
|
||||
if (selected?.employeePositionId) {
|
||||
Cookies.set("current-position-id", selected.employeePositionId);
|
||||
setPositionCookie(selected.employeePositionId);
|
||||
}
|
||||
applyDelegationCookie(selected);
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
} from "@/record-management/dto/userRecords/teetersAndSignatureDto";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import { getPositionCookie } from "@/shared/utils/positionCookie";
|
||||
|
||||
export const withHeaders = (passPosId: boolean = false) => {
|
||||
const unitId = Cookies.get("unit-id");
|
||||
const positionId = Cookies.get("current-position-id");
|
||||
const positionId = getPositionCookie();
|
||||
const projectId = Cookies.get("current-project-id");
|
||||
const delegatedPositionId = Cookies.get("delegatedPositionId");
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import { getPositionCookie } from "@/shared/utils/positionCookie";
|
||||
|
||||
export const withHeaders = () => {
|
||||
const tenantKey = Cookies.get("tenant-key");
|
||||
const unitId = Cookies.get("unit-id");
|
||||
const positionId = Cookies.get("current-position-id");
|
||||
const positionId = getPositionCookie();
|
||||
const projectId = Cookies.get("current-project-id");
|
||||
const delegatedPositionId = Cookies.get("delegatedPositionId");
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerAccount,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
@@ -84,6 +85,7 @@ import type {
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
MarshallingStop,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
@@ -151,6 +153,15 @@ import type {
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
ZoneLayout,
|
||||
ZoneSlotSummary,
|
||||
WarehouseZoneStack,
|
||||
WarehouseZoneSlot,
|
||||
SaveStackPayload,
|
||||
AvailableSlot,
|
||||
ContainerAccessibility,
|
||||
FindSlotPayload,
|
||||
} from "@/types/warehouse";
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
@@ -285,6 +296,11 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
// A station's loading window gates the warehouse loading queues too — they
|
||||
// render the same Start/End controls, so they must refresh on the same click.
|
||||
["loadable-trains"],
|
||||
["train-loadable-items"],
|
||||
["warehouse-inventory"],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -595,6 +611,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id),
|
||||
),
|
||||
|
||||
marshallingStops: endpoint<{ id: string }, MarshallingStop[]>(
|
||||
"train-scheduling",
|
||||
"marshalling-stops",
|
||||
({ id }) => trainSchedulingService.getMarshallingStops(id),
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.marshallingStops(id),
|
||||
),
|
||||
|
||||
unassignedBookings: endpoint<
|
||||
{ scheduleId: string },
|
||||
UnassignedBookingsResponse
|
||||
@@ -1215,6 +1238,14 @@ export const api = {
|
||||
() => [["warehouses"], ["warehouse-yards"]],
|
||||
),
|
||||
|
||||
deleteYard: endpoint<{ id: string }, unknown>(
|
||||
"warehouses",
|
||||
"deleteYard",
|
||||
({ id }) => warehouseService.removeYard(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouses"], ["warehouse-yards"]],
|
||||
),
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────
|
||||
listZones: endpoint<{ yardId: string }, WarehouseZone[]>(
|
||||
"warehouses",
|
||||
@@ -1247,6 +1278,100 @@ export const api = {
|
||||
() => [["warehouse-yards"]],
|
||||
),
|
||||
|
||||
zoneContents: endpoint<{ zoneId: string }, ZoneContentItem[]>(
|
||||
"warehouse-zones",
|
||||
"contents",
|
||||
({ zoneId }) => warehouseService.zoneContents(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "contents"],
|
||||
),
|
||||
|
||||
zoneLayout: endpoint<{ zoneId: string }, ZoneLayout>(
|
||||
"warehouse-zones",
|
||||
"layout",
|
||||
({ zoneId }) => warehouseService.zoneLayout(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "layout"],
|
||||
),
|
||||
|
||||
zoneSlotSummary: endpoint<{ zoneId: string }, ZoneSlotSummary>(
|
||||
"warehouse-zones",
|
||||
"slot-summary",
|
||||
({ zoneId }) => warehouseService.zoneSlotSummary(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "slot-summary"],
|
||||
),
|
||||
|
||||
// ── Ground stacks and slots ────────────────────────────────────────────
|
||||
listStacks: endpoint<{ zoneId: string }, WarehouseZoneStack[]>(
|
||||
"warehouse-zone-stacks",
|
||||
"list",
|
||||
({ zoneId }) => warehouseService.listStacks(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zone-stacks", zoneId],
|
||||
),
|
||||
|
||||
createStack: endpoint<
|
||||
{ zoneId: string; payload: SaveStackPayload },
|
||||
WarehouseZoneStack
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"create",
|
||||
({ zoneId, payload }) =>
|
||||
warehouseService.createStack(zoneId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
updateStack: endpoint<
|
||||
{ id: string; zoneId: string; payload: Partial<SaveStackPayload> },
|
||||
WarehouseZoneStack
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"update",
|
||||
({ id, payload }) => warehouseService.updateStack(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
deleteStack: endpoint<{ id: string; zoneId: string }, unknown>(
|
||||
"warehouse-zone-stacks",
|
||||
"delete",
|
||||
({ id }) => warehouseService.removeStack(id).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
updateSlot: endpoint<
|
||||
{ slotId: string; zoneId: string; payload: { status?: string; isActive?: boolean } },
|
||||
WarehouseZoneSlot
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"update-slot",
|
||||
({ slotId, payload }) => warehouseService.updateSlot(slotId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
deleteZone: endpoint<{ id: string }, unknown>(
|
||||
"warehouses",
|
||||
"deleteZone",
|
||||
({ id }) => warehouseService.removeZone(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouses"], ["warehouse-yards"]],
|
||||
),
|
||||
|
||||
// ── Inventory (queries) ────────────────────────────────────────────────
|
||||
listInventory: endpoint<
|
||||
{ filter?: InventoryFilter },
|
||||
@@ -1470,7 +1595,7 @@ export const api = {
|
||||
"store",
|
||||
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
reserve: endpoint<ReserveInventoryPayload, WarehouseInventoryItem>(
|
||||
@@ -1509,6 +1634,36 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
findAvailableSlot: endpoint<FindSlotPayload, AvailableSlot | null>(
|
||||
"warehouse-inventory",
|
||||
"find-slot",
|
||||
(payload) => warehouseService.findAvailableSlot(payload).then((r) => r.data),
|
||||
(payload) => ["warehouse-inventory", "find-slot", payload],
|
||||
),
|
||||
|
||||
containerAccessibility: endpoint<{ id: string }, ContainerAccessibility>(
|
||||
"warehouse-inventory",
|
||||
"accessibility",
|
||||
({ id }) => warehouseService.containerAccessibility(id).then((r) => r.data),
|
||||
({ id }) => ["warehouse-inventory", id, "accessibility"],
|
||||
),
|
||||
|
||||
assignSlot: endpoint<{ id: string; slotId?: string }, WarehouseInventoryItem>(
|
||||
"warehouse-inventory",
|
||||
"assign-slot",
|
||||
({ id, slotId }) => warehouseService.assignSlot(id, slotId).then((r) => r.data),
|
||||
undefined,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
releaseSlot: endpoint<{ id: string }, WarehouseInventoryItem>(
|
||||
"warehouse-inventory",
|
||||
"release-slot",
|
||||
({ id }) => warehouseService.releaseSlot(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
move: endpoint<
|
||||
{ id: string; payload: MoveInventoryPayload },
|
||||
WarehouseInventoryItem
|
||||
@@ -1518,7 +1673,7 @@ export const api = {
|
||||
({ id, payload }) =>
|
||||
warehouseService.move(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
markReadyForPickup: endpoint<string, WarehouseInventoryItem>(
|
||||
@@ -3267,6 +3422,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||
),
|
||||
|
||||
accounts: endpoint<{ companyId: string }, CustomerAccount[]>(
|
||||
"customers",
|
||||
"accounts",
|
||||
({ companyId }) => customersService.accounts(companyId),
|
||||
({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId),
|
||||
),
|
||||
|
||||
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
||||
"customers",
|
||||
"resetTarget",
|
||||
|
||||
@@ -73,6 +73,18 @@ export interface BookingListFilter {
|
||||
freightType?: string;
|
||||
/** Service type (rule-engine service_types.id). */
|
||||
serviceTypeId?: string;
|
||||
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
||||
cargoTypeId?: string;
|
||||
/** Contains-search over content: cargo description, commodity, container types. */
|
||||
cargoText?: string;
|
||||
/** Bookings carrying this container type; also scopes containersMin/Max to it. */
|
||||
containerTypeId?: string;
|
||||
/** Container count bounds — of containerTypeId when set, else of all types. */
|
||||
containersMin?: string;
|
||||
containersMax?: string;
|
||||
/** Bounds on containers declared on the shipment request behind the booking. */
|
||||
requestedContainersMin?: string;
|
||||
requestedContainersMax?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
||||
@@ -199,6 +211,15 @@ export const bookingsService = {
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.cargoText) params.cargoText = filter.cargoText;
|
||||
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
|
||||
if (filter.containersMin) params.containersMin = filter.containersMin;
|
||||
if (filter.containersMax) params.containersMax = filter.containersMax;
|
||||
if (filter.requestedContainersMin)
|
||||
params.requestedContainersMin = filter.requestedContainersMin;
|
||||
if (filter.requestedContainersMax)
|
||||
params.requestedContainersMax = filter.requestedContainersMax;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency)
|
||||
@@ -238,6 +259,15 @@ export const bookingsService = {
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.cargoText) params.cargoText = filter.cargoText;
|
||||
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
|
||||
if (filter.containersMin) params.containersMin = filter.containersMin;
|
||||
if (filter.containersMax) params.containersMax = filter.containersMax;
|
||||
if (filter.requestedContainersMin)
|
||||
params.requestedContainersMin = filter.requestedContainersMin;
|
||||
if (filter.requestedContainersMax)
|
||||
params.requestedContainersMax = filter.requestedContainersMax;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerAccount,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
@@ -90,6 +91,18 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Every portal login belonging to this customer, primary contact first.
|
||||
*
|
||||
* Not filtered to active accounts — a suspended or never-activated login is
|
||||
* exactly what staff are checking when a customer says they cannot sign in.
|
||||
*/
|
||||
accounts(companyId: string): Promise<CustomerAccount[]> {
|
||||
return apiClient
|
||||
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* The IAM account a reset link would go to. Read before offering the action
|
||||
* so staff see the credentials the link actually reaches, not the company's
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { api as client } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import type {
|
||||
EmptyReturnQuote,
|
||||
EmptyReturnRequest,
|
||||
EmptyReturnRequestStatus,
|
||||
PlannedEmptyReturn,
|
||||
} from '@/types/importOperations';
|
||||
|
||||
/**
|
||||
* Empty container return requests — the customer-initiated path for a booking
|
||||
* that was sold WITHOUT the return service. Staff price and approve them here;
|
||||
* the customer pays and books the truck from the portal.
|
||||
*/
|
||||
export const emptyReturnRequestsService = {
|
||||
list: async (params: {
|
||||
status?: EmptyReturnRequestStatus;
|
||||
bookingId?: string;
|
||||
} = {}): Promise<EmptyReturnRequest[]> => {
|
||||
const response = await client.get<EmptyReturnRequest[]>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.BASE,
|
||||
{ params },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Scheduled returns the warehouse is expecting, with date and truck. */
|
||||
planned: async (): Promise<PlannedEmptyReturn[]> => {
|
||||
const response = await client.get<PlannedEmptyReturn[]>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.PLANNED,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** The route price staff see prefilled at approval. */
|
||||
quote: async (bookingId: string): Promise<EmptyReturnQuote> => {
|
||||
const response = await client.get<{ quote: EmptyReturnQuote }>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.ELIGIBILITY(bookingId),
|
||||
);
|
||||
return unwrap(response.data).quote;
|
||||
},
|
||||
|
||||
approve: async (
|
||||
id: string,
|
||||
payload: { unitAmount?: number; currency?: string } = {},
|
||||
): Promise<EmptyReturnRequest> => {
|
||||
const response = await client.post<EmptyReturnRequest>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.APPROVE(id),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
reject: async (id: string, reason: string): Promise<EmptyReturnRequest> => {
|
||||
const response = await client.post<EmptyReturnRequest>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.REJECT(id),
|
||||
{ reason },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
LoadEmptyContainersOnTrainPayload,
|
||||
DjiboutiIncident,
|
||||
EmptyContainerReturn,
|
||||
EmptyReturnBooking,
|
||||
ImportCustomsFinalization,
|
||||
ImportOperationActionPayload,
|
||||
RecordDeclarationPayload,
|
||||
@@ -114,6 +115,14 @@ export const importOperationsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Bookings that ship with empty-container return and still owe empties back. */
|
||||
listEmptyReturnBookings: async (): Promise<EmptyReturnBooking[]> => {
|
||||
const response = await client.get<EmptyReturnBooking[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_RETURN_BOOKINGS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
createEmptyReturn: async (
|
||||
payload: CreateEmptyContainerReturnPayload,
|
||||
): Promise<EmptyContainerReturn> => {
|
||||
@@ -124,6 +133,17 @@ export const importOperationsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Backfill empties already in the yard. All-or-nothing on the server. */
|
||||
bulkCreateEmptyReturns: async (
|
||||
returns: CreateEmptyContainerReturnPayload[],
|
||||
): Promise<EmptyContainerReturn[]> => {
|
||||
const response = await client.post<EmptyContainerReturn[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS_BULK,
|
||||
{ returns },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadEmptyContainersOnTrain: async (
|
||||
payload: LoadEmptyContainersOnTrainPayload,
|
||||
): Promise<EmptyContainerReturn[]> => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
MarshallingStop,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
UpdateScheduleWindowRulePayload,
|
||||
@@ -765,6 +766,24 @@ export const trainSchedulingService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMarshallingStops: async (scheduleId: string): Promise<MarshallingStop[]> => {
|
||||
const response = await client.get<MarshallingStop[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_STOPS(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
downloadMarshallingDocumentAt: async (
|
||||
scheduleId: string,
|
||||
stopIndex: number,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_DOCUMENT_AT(scheduleId, stopIndex),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
|
||||
@@ -5,6 +5,14 @@ import { api as apiClient } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ZoneOccupancy,
|
||||
ZoneLayout,
|
||||
ZoneSlotSummary,
|
||||
WarehouseZoneStack,
|
||||
WarehouseZoneSlot,
|
||||
SaveStackPayload,
|
||||
AvailableSlot,
|
||||
ContainerAccessibility,
|
||||
FindSlotPayload,
|
||||
TruckOnSite,
|
||||
WarehouseOpsStats,
|
||||
WarehouseThroughputPoint,
|
||||
@@ -41,6 +49,7 @@ import type {
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
RegisterBacklogContainerPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
EligibleBooking,
|
||||
@@ -71,7 +80,9 @@ import type {
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
} from '@/types/warehouse';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
@@ -105,6 +116,10 @@ export interface LoadableTrain {
|
||||
departureTime: string | null;
|
||||
readyCount: number;
|
||||
loadedCount: number;
|
||||
/** freight.yards.id the train departs from. */
|
||||
originStationId: string | null;
|
||||
/** The schedule's per-yard loading/unloading windows — same store the train schedule page writes. */
|
||||
stationWorkLogs: Record<string, StationWorkLog> | null;
|
||||
}
|
||||
|
||||
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
|
||||
@@ -122,6 +137,11 @@ export interface TrainLoadableItem {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
/** The booking's boarding yard — the yard whose loading window gates this item. */
|
||||
originYardId: string | null;
|
||||
originYardLabel: string | null;
|
||||
/** True once "Start loading" was clicked for this item's boarding yard on this train. */
|
||||
loadingWindowStarted: boolean;
|
||||
loadable: boolean;
|
||||
}
|
||||
|
||||
@@ -186,7 +206,7 @@ export const warehouseService = {
|
||||
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||
getContainerWeights: async (
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||
);
|
||||
@@ -257,6 +277,7 @@ export const warehouseService = {
|
||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||
remove: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
||||
// Yards list now returns the standard paginated envelope ({ items, meta }).
|
||||
listFacilities: () =>
|
||||
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
||||
@@ -272,6 +293,7 @@ export const warehouseService = {
|
||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
||||
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
|
||||
removeYard: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────
|
||||
listZones: (yardId: string) =>
|
||||
@@ -282,6 +304,40 @@ export const warehouseService = {
|
||||
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
||||
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
|
||||
removeZone: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
zoneContents: (zoneId: string) =>
|
||||
apiClient.get<ZoneContentItem[]>(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`),
|
||||
zoneLayout: (zoneId: string) =>
|
||||
apiClient.get<ZoneLayout>(URL_CONSTANTS.WAREHOUSE_ZONES.LAYOUT(zoneId)),
|
||||
zoneSlotSummary: (zoneId: string) =>
|
||||
apiClient.get<ZoneSlotSummary>(URL_CONSTANTS.WAREHOUSE_ZONES.SLOT_SUMMARY(zoneId)),
|
||||
|
||||
// ── Ground stacks and slots ────────────────────────────────────────────
|
||||
listStacks: (zoneId: string) =>
|
||||
apiClient.get<WarehouseZoneStack[]>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ZONE(zoneId)),
|
||||
createStack: (zoneId: string, payload: SaveStackPayload) =>
|
||||
apiClient.post<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BASE, {
|
||||
...payload,
|
||||
zoneId,
|
||||
}),
|
||||
updateStack: (id: string, payload: Partial<SaveStackPayload>) =>
|
||||
apiClient.patch<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id), payload),
|
||||
removeStack: (id: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id)),
|
||||
updateSlot: (slotId: string, payload: { status?: string; isActive?: boolean }) =>
|
||||
apiClient.patch<WarehouseZoneSlot>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.SLOT(slotId), payload),
|
||||
|
||||
// ── Physical placement ─────────────────────────────────────────────────
|
||||
findAvailableSlot: (payload: FindSlotPayload) =>
|
||||
apiClient.post<AvailableSlot | null>(URL_CONSTANTS.WAREHOUSE_INVENTORY.FIND_SLOT, payload),
|
||||
assignSlot: (id: string, slotId?: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ASSIGN_SLOT(id), {
|
||||
slotId,
|
||||
}),
|
||||
releaseSlot: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_SLOT(id), {}),
|
||||
containerAccessibility: (id: string) =>
|
||||
apiClient.get<ContainerAccessibility>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACCESSIBILITY(id)),
|
||||
|
||||
// ── Inventory ──────────────────────────────────────────────────────────
|
||||
listInventory: (filter?: InventoryFilter) =>
|
||||
@@ -351,6 +407,20 @@ export const warehouseService = {
|
||||
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||
/** Register one loaded container already in the yard but never entered. */
|
||||
registerBacklogContainer: (payload: RegisterBacklogContainerPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG,
|
||||
payload,
|
||||
),
|
||||
|
||||
/** Bulk backlog registration — all-or-nothing on the server. */
|
||||
registerBacklogContainersBulk: (containers: RegisterBacklogContainerPayload[]) =>
|
||||
apiClient.post<WarehouseInventoryItem[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG_BULK,
|
||||
{ containers },
|
||||
),
|
||||
|
||||
receiveBulk: (payload: BulkReceivePayload) =>
|
||||
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
persistRememberMePreference,
|
||||
setAuthCookies,
|
||||
} from "@/shared/utils/authPersistence";
|
||||
import {
|
||||
getPositionCookie,
|
||||
setPositionCookie,
|
||||
} from "@/shared/utils/positionCookie";
|
||||
import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -46,7 +50,7 @@ export const useAuthUser = () => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const delegatedPositionId = Cookies.get("delegatedPositionId");
|
||||
const currentPositionId = Cookies.get("current-position-id");
|
||||
const currentPositionId = getPositionCookie();
|
||||
|
||||
const {
|
||||
setUser,
|
||||
@@ -96,7 +100,7 @@ export const useAuthUser = () => {
|
||||
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
|
||||
if (firstPositionId) {
|
||||
setSelectedPositionId(firstPositionId);
|
||||
Cookies.set("current-position-id", firstPositionId, cookieOptions);
|
||||
setPositionCookie(firstPositionId, cookieOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +145,7 @@ export const useAuthUser = () => {
|
||||
|
||||
if (fallbackId) {
|
||||
setSelectedPositionId(fallbackId);
|
||||
Cookies.set("current-position-id", fallbackId);
|
||||
setPositionCookie(fallbackId);
|
||||
}
|
||||
} else if (selectedPositionId) {
|
||||
// Self-heal stale cookies that were set to position.id instead of
|
||||
@@ -157,10 +161,7 @@ export const useAuthUser = () => {
|
||||
|
||||
if (matchingPosition?.employeePositionId) {
|
||||
setSelectedPositionId(matchingPosition.employeePositionId);
|
||||
Cookies.set(
|
||||
"current-position-id",
|
||||
matchingPosition.employeePositionId,
|
||||
);
|
||||
setPositionCookie(matchingPosition.employeePositionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getAuthCookieOptions,
|
||||
setAuthCookies,
|
||||
} from "@/shared/utils/authPersistence";
|
||||
import { setPositionCookie } from "@/shared/utils/positionCookie";
|
||||
import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
|
||||
|
||||
function unwrapApiData<T>(payload: T | { data?: T }): T {
|
||||
@@ -138,7 +139,7 @@ export async function persistFaydaRegistrationAuth(
|
||||
const firstPositionId =
|
||||
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
|
||||
if (firstPositionId) {
|
||||
Cookies.set("current-position-id", firstPositionId, cookieOptions);
|
||||
setPositionCookie(firstPositionId, cookieOptions);
|
||||
}
|
||||
|
||||
return userDetails;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[2]>;
|
||||
|
||||
/**
|
||||
* Freight's own active-position cookie.
|
||||
*
|
||||
* Smart Office is a separate app on the same IAM and it also writes a cookie
|
||||
* named `current-position-id` — but it stores `position.id` where freight
|
||||
* stores `employeePositionId`. The two are not interchangeable, so on a shared
|
||||
* domain each app's login silently overwrote the other's desk selection and the
|
||||
* loser fell back to `positions[0]`. Freight keeps its own cookie name so both
|
||||
* can hold a selection at once.
|
||||
*/
|
||||
export const POSITION_COOKIE = "freight-current-position-id";
|
||||
|
||||
/**
|
||||
* The pre-rename, shared-with-Smart-Office name. Still read so a session that
|
||||
* is live across the deploy keeps its desk, and cleared on every write so the
|
||||
* colliding cookie does not linger.
|
||||
*/
|
||||
const LEGACY_POSITION_COOKIE = "current-position-id";
|
||||
|
||||
/** The active `employeePositionId`, or undefined when no desk is selected. */
|
||||
export const getPositionCookie = (): string | undefined =>
|
||||
Cookies.get(POSITION_COOKIE) ?? Cookies.get(LEGACY_POSITION_COOKIE);
|
||||
|
||||
export const setPositionCookie = (
|
||||
value: string,
|
||||
options?: CookieOptions,
|
||||
): void => {
|
||||
Cookies.set(POSITION_COOKIE, value, options);
|
||||
Cookies.remove(LEGACY_POSITION_COOKIE);
|
||||
};
|
||||
|
||||
export const clearPositionCookie = (): void => {
|
||||
Cookies.remove(POSITION_COOKIE);
|
||||
Cookies.remove(LEGACY_POSITION_COOKIE);
|
||||
};
|
||||
@@ -8,6 +8,8 @@
|
||||
* API so the data layer can be swapped to live endpoints with no UI changes.
|
||||
*/
|
||||
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
|
||||
/** Mirrors backend `CompanyType`. */
|
||||
export type CompanyType =
|
||||
| "customer"
|
||||
@@ -65,6 +67,15 @@ export interface CompanyProfile {
|
||||
/** Business-license documents uploaded for this profile. */
|
||||
licenseFiles?: LicenseFile[];
|
||||
attributes?: Record<string, unknown> | null;
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as.
|
||||
*
|
||||
* A TIN routinely holds a dozen licences split by activity, so "exporter" and
|
||||
* "freight forwarder" are usually two different businesses under one company.
|
||||
* Null when the customer has not attached one, or when the company registered
|
||||
* without eTrade at all (co-operative / investment licence).
|
||||
*/
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
/** Reviewer note when the role is rejected. */
|
||||
reviewNote?: string | null;
|
||||
createdAt: string;
|
||||
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
|
||||
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
||||
* details and routinely differ from the credentials the customer logs in with.
|
||||
*/
|
||||
/**
|
||||
* One portal login belonging to a customer: the company-side profile joined to
|
||||
* the IAM account that actually signs in. Mirrors the API's `CustomerAccount`.
|
||||
*
|
||||
* The IAM fields are null when the profile points at a user row that no longer
|
||||
* exists — surfaced rather than hidden, since that is itself a fault worth
|
||||
* seeing.
|
||||
*/
|
||||
export interface CustomerAccount {
|
||||
profileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
phoneVerified: boolean | null;
|
||||
status: string | null;
|
||||
isActive: boolean | null;
|
||||
/** False means the account exists but its owner never set a password. */
|
||||
hasSetPassword: boolean | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerResetTarget {
|
||||
userId: string;
|
||||
name: string;
|
||||
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
nationality?: CompanyNationality;
|
||||
/**
|
||||
* Only companies holding this operational role. Distinct from `type`, which
|
||||
* is the company's own kind — a `customer` company can hold importer,
|
||||
* exporter and forwarder roles at once, and its other roles still come back
|
||||
* on the row.
|
||||
*/
|
||||
profileType?: ProfileType;
|
||||
/** ISO instants — inclusive bounds on the registration date. */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
|
||||
@@ -92,7 +92,11 @@ export interface EmptyContainerReturn {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
bookingId: string | null;
|
||||
/** Reference of the booking this empty came back on; null for standalone returns. */
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
/** Owning company as text — set for backfilled boxes whose company is unregistered. */
|
||||
companyName: string | null;
|
||||
returnDate: string;
|
||||
facility: string | null;
|
||||
yard: string | null;
|
||||
@@ -117,11 +121,39 @@ export interface EmptyContainerReturn {
|
||||
|
||||
export type EmptyContainerSize = '20' | '40';
|
||||
|
||||
/** One container a with-return booking owes back empty. */
|
||||
export interface EmptyReturnBookingContainer {
|
||||
/** Stable row key — the booking container unit id. */
|
||||
key: string;
|
||||
unitId: string;
|
||||
containerNumber: string;
|
||||
containerSize: string | null;
|
||||
containerType: string | null;
|
||||
/** Set once this container's empty return has been recorded. */
|
||||
returnId: string | null;
|
||||
returnStatus: EmptyContainerReturnStatus | null;
|
||||
}
|
||||
|
||||
/** A booking that ships with empty-container return and still owes empties. */
|
||||
export interface EmptyReturnBooking {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
bookingStatus: string;
|
||||
equipmentReturn: string;
|
||||
customerId: string | null;
|
||||
companyName: string | null;
|
||||
containers: EmptyReturnBookingContainer[];
|
||||
expectedCount: number;
|
||||
recordedCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export interface CreateEmptyContainerReturnPayload {
|
||||
containerNumber: string;
|
||||
containerSize?: EmptyContainerSize;
|
||||
bookingId?: string;
|
||||
customerId?: string;
|
||||
companyName?: string;
|
||||
returnDate?: string;
|
||||
facility?: string;
|
||||
yard?: string;
|
||||
@@ -147,3 +179,60 @@ export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperation
|
||||
wagonAllocationReference?: string;
|
||||
handoverNote?: string;
|
||||
}
|
||||
|
||||
export type EmptyReturnRequestStatus =
|
||||
| 'SUBMITTED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'PAID'
|
||||
| 'SCHEDULED'
|
||||
| 'COMPLETED'
|
||||
| 'CANCELLED';
|
||||
|
||||
/** A customer's request to send empties back on a booking sold without return. */
|
||||
export interface EmptyReturnRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
status: EmptyReturnRequestStatus;
|
||||
containerNumbers: string[];
|
||||
containerCount: number;
|
||||
quotedUnitAmount: number | null;
|
||||
quotedTotalAmount: number | null;
|
||||
currency: string | null;
|
||||
invoiceId: string | null;
|
||||
paidAt: string | null;
|
||||
requestedReturnDate: string | null;
|
||||
truckPlateNumber: string | null;
|
||||
truckDriverName: string | null;
|
||||
truckType: string | null;
|
||||
scheduledAt: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
/** Per-container price for an empty return, off the route's WITH_RETURN rate. */
|
||||
export interface EmptyReturnQuote {
|
||||
unitAmount: number | null;
|
||||
currency: string;
|
||||
sourceRateUsd: number | null;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
/** A scheduled empty return the warehouse is waiting on. */
|
||||
export interface PlannedEmptyReturn {
|
||||
requestId: string;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
companyName: string | null;
|
||||
companyId: string | null;
|
||||
requestedReturnDate: string | null;
|
||||
truckPlateNumber: string | null;
|
||||
truckDriverName: string | null;
|
||||
truckType: string | null;
|
||||
containers: Array<{ containerNumber: string; returnId: string | null }>;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface InvoiceListFilter {
|
||||
statuses?: string;
|
||||
/** CSV of `Freight.InvoiceSource` values. */
|
||||
sources?: string;
|
||||
/** CSV of invoice types (see `INVOICE_TYPE_OPTIONS`). */
|
||||
types?: string;
|
||||
/** CSV of EIMS filing states. */
|
||||
eimsStatuses?: string;
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
@@ -118,6 +120,11 @@ export interface OfflineUsdInvoice extends Invoice {
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
/**
|
||||
* Outstanding `balanceAmount` across the whole filtered set (not the visible
|
||||
* page), keyed by normalised currency — feeds the worklist's KPI strip.
|
||||
*/
|
||||
outstanding: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
@@ -166,3 +173,28 @@ export function invoicePaymentMethod(invoice: Invoice): string | null {
|
||||
/** Human label for a method value; unknown values are shown as-is. */
|
||||
export const paymentMethodLabel = (method: string): string =>
|
||||
PAYMENT_METHOD_LABELS.get(method) ?? method;
|
||||
|
||||
/**
|
||||
* What an invoice bills for. Every billing source mints its own `type` string,
|
||||
* so this list is the known vocabulary, not a closed enum — render an unknown
|
||||
* value rather than treating it as invalid.
|
||||
*/
|
||||
export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "PREPAID", label: "Prepaid freight" },
|
||||
{ value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" },
|
||||
{ value: "GL_FINAL", label: "General contract final" },
|
||||
{ value: "ADDITIONAL_CHARGE", label: "Additional charge" },
|
||||
{ value: "PORT_CHARGES", label: "Port charges" },
|
||||
{ value: "MISCELLANEOUS", label: "Miscellaneous" },
|
||||
{ value: "DELIVERY_FEE", label: "Delivery fee" },
|
||||
{ value: "LAST_MILE_ADVANCE", label: "Last-mile advance" },
|
||||
{ value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" },
|
||||
{ value: "STORAGE_FEE", label: "Storage fee" },
|
||||
{ value: "DEMURRAGE", label: "Demurrage" },
|
||||
{ value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" },
|
||||
];
|
||||
|
||||
/** Label for an invoice `type`, falling back to the humanised raw value. */
|
||||
export const invoiceTypeLabel = (type: string): string =>
|
||||
INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ??
|
||||
type.replace(/_/g, " ");
|
||||
|
||||
@@ -937,6 +937,19 @@ export interface TrainCheckpoint {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One corridor stop with a logged consist change (coupled/uncoupled/switched)
|
||||
* — its own numbered marshalling document exists. `stopIndex` starts at 2;
|
||||
* origin is always Marshalling 1 (the plain import/export load list, not
|
||||
* this list). A stop with only a routine checkpoint never appears here.
|
||||
*/
|
||||
export interface MarshallingStop {
|
||||
stopIndex: number;
|
||||
yardId: string;
|
||||
yardLabel: string;
|
||||
firstOccurredAt: string;
|
||||
}
|
||||
|
||||
/** The four station-work stamps, as a payload fragment both endpoints accept. */
|
||||
export interface CheckpointHandlingTimes {
|
||||
unloadingStartedAt?: string | null;
|
||||
|
||||
@@ -145,6 +145,134 @@ export interface WarehouseYard {
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
export const WAREHOUSE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStackStatus = (typeof WAREHOUSE_STACK_STATUSES)[number];
|
||||
|
||||
/** Stored slot intent. OCCUPIED is never stored — it is derived from inventory. */
|
||||
export const WAREHOUSE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
|
||||
export type WarehouseSlotStatus = (typeof WAREHOUSE_SLOT_STATUSES)[number];
|
||||
|
||||
export type SlotEffectiveStatus = WarehouseSlotStatus | 'OCCUPIED';
|
||||
|
||||
/** One vertical level of a ground stack. */
|
||||
export interface WarehouseZoneSlot {
|
||||
id: string;
|
||||
stackId: string;
|
||||
level: number;
|
||||
status: WarehouseSlotStatus;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** One ground footprint inside a zone — where containers are stacked vertically. */
|
||||
export interface WarehouseZoneStack {
|
||||
id: string;
|
||||
zoneId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
row: string | null;
|
||||
bay: string | null;
|
||||
position: string | null;
|
||||
maxStackHeight: number;
|
||||
status: WarehouseStackStatus;
|
||||
isActive: boolean;
|
||||
slots?: WarehouseZoneSlot[];
|
||||
}
|
||||
|
||||
export interface SaveStackPayload {
|
||||
code: string;
|
||||
name?: string;
|
||||
row?: string;
|
||||
bay?: string;
|
||||
position?: string;
|
||||
maxStackHeight?: number;
|
||||
status?: WarehouseStackStatus;
|
||||
}
|
||||
|
||||
/** Configured capacity vs slots actually built vs slots actually full. */
|
||||
export interface ZoneSlotSummary {
|
||||
configuredCapacity: number | null;
|
||||
physicalSlotCount: number;
|
||||
occupiedSlotCount: number;
|
||||
reservedSlotCount: number;
|
||||
blockedSlotCount: number;
|
||||
inactiveSlotCount: number;
|
||||
availableSlotCount: number;
|
||||
/** True when more slots are built than the configured capacity allows. */
|
||||
inconsistent: boolean;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutSlot {
|
||||
slotId: string;
|
||||
level: number;
|
||||
effectiveStatus: SlotEffectiveStatus;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutStack {
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
status: WarehouseStackStatus;
|
||||
isActive: boolean;
|
||||
/** Highest level first, the way the yard is seen from the side. */
|
||||
slots: ZoneLayoutSlot[];
|
||||
}
|
||||
|
||||
export interface ZoneLayout {
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneName: string;
|
||||
stacks: ZoneLayoutStack[];
|
||||
summary: ZoneSlotSummary;
|
||||
}
|
||||
|
||||
export interface FindSlotPayload {
|
||||
yardId: string;
|
||||
zoneId?: string;
|
||||
direction?: 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
cargoTypeId?: string;
|
||||
}
|
||||
|
||||
/** The lowest free stack level the placement engine would use next. */
|
||||
export interface AvailableSlot {
|
||||
slotId: string;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
level: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
}
|
||||
|
||||
export interface BlockingContainer {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
level: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Whether a container can be lifted out, and what is stacked on top of it. */
|
||||
export interface ContainerAccessibility {
|
||||
accessible: boolean;
|
||||
inventoryId: string;
|
||||
stackCode: string | null;
|
||||
level: number | null;
|
||||
blockingContainers: BlockingContainer[];
|
||||
}
|
||||
|
||||
/** One container (or bulk lot) currently sitting in a zone. */
|
||||
export interface ZoneContentItem {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
unloadedAt: string | null;
|
||||
containerType: string | null;
|
||||
direction: 'IMPORT' | 'EXPORT' | null;
|
||||
loadState: string | null;
|
||||
status: string;
|
||||
bookingReference: string | null;
|
||||
}
|
||||
|
||||
export const FACILITY_TYPES = [
|
||||
'PORT',
|
||||
'DRY_PORT',
|
||||
@@ -168,11 +296,16 @@ export interface Facility {
|
||||
|
||||
export type WarehouseFacility = Facility;
|
||||
|
||||
/** What a warehouse handles. Null = both. */
|
||||
export const WAREHOUSE_FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
export type WarehouseFreightType = (typeof WAREHOUSE_FREIGHT_TYPES)[number];
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
freightType: WarehouseFreightType | null;
|
||||
stationId: string | null;
|
||||
facilityId: string | null;
|
||||
facility?: Facility | null;
|
||||
@@ -367,6 +500,8 @@ export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Exact stack level in the destination zone. Container yards only. */
|
||||
slotId?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
@@ -442,6 +577,15 @@ export interface EligibleBooking {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
containerUnits: Array<{
|
||||
containerNumber: string;
|
||||
containerSize: string | null;
|
||||
weightTons: number;
|
||||
received: boolean;
|
||||
grnNumber: string | null;
|
||||
}>;
|
||||
receivedContainerCount: number;
|
||||
remainingContainerCount: number;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -450,13 +594,23 @@ export interface BulkReceivePayload {
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
containerNumbers?: string[];
|
||||
truckEntrance?: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
receivedCount: number;
|
||||
skippedCount: number;
|
||||
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
||||
results: {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
inventoryId?: string;
|
||||
inventoryIds?: string[];
|
||||
grnNumber?: string;
|
||||
receivedContainers?: number;
|
||||
remainingContainers?: number;
|
||||
reason?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TruckEntrancePayload {
|
||||
@@ -642,6 +796,8 @@ export interface StoreInventoryPayload {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
/** Exact stack level. Omit to let the placement engine pick the lowest free one. */
|
||||
slotId?: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
@@ -1033,6 +1189,7 @@ export interface SaveWarehousePayload {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
freightType?: WarehouseFreightType | null;
|
||||
stationId?: string;
|
||||
facilityId?: string;
|
||||
locationName?: string;
|
||||
@@ -1065,6 +1222,26 @@ export interface SaveZonePayload {
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A loaded container already sitting in a yard but never entered in the system.
|
||||
* `arrivedAt` is the true historical arrival — the row is flagged as a backlog
|
||||
* registration server-side and accrues no storage or demurrage.
|
||||
*/
|
||||
export interface RegisterBacklogContainerPayload {
|
||||
containerNumber: string;
|
||||
containerTypeId: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
arrivedAt: string;
|
||||
companyId?: string;
|
||||
companyName?: string;
|
||||
sealNumber?: string;
|
||||
weight?: number;
|
||||
volume?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ReceiveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
@@ -1084,6 +1261,8 @@ export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Exact stack level in the destination zone. Container yards only. */
|
||||
slotId?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user