Move within warehouses

This commit is contained in:
hagiye
2026-06-16 12:46:02 +03:00
parent f5d6d8e7da
commit 63f177e6d0
11 changed files with 412 additions and 213 deletions

View File

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

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import { ArrowLeftRight, ClipboardCheck, PackageCheck } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -9,6 +9,7 @@ interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
onMove?: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
}
@@ -23,6 +24,7 @@ export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
onMove,
busyId,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
@@ -85,6 +87,17 @@ export function WarehouseInventoryTable({
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<ArrowLeftRight size={14} />}
disabled={!onMove || busy}
loading={busy}
onClick={() => onMove?.(item)}
>
Move
</Button>
<Button
size="compact-xs"
variant="light"