feat(warehouses): expandable booking rows list containers / cargo inline

Every booking row in the queue tables (export received, ready-to-load,
loaded/dispatch, import unloaded) gets a chevron that expands to the
booking's items: container number, goods, lifecycle stage badge
(PENDING..DELIVERED), carrying truck, and GRN.

Bulk bookings (no container units) show a one-line summary instead
("Bulk cargo — Coffee export cargo, 3,200 t"). The expansion reuses the
['container-items', bookingId] query the container modal already uses, so
data is shared and instant after either has loaded it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 09:51:36 +00:00
parent 3ff4b8bf94
commit 707923b603

View File

@@ -257,6 +257,83 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
const SUB_STAGE_COLOR: Record<string, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/**
* Expanded booking row: the booking's containers / bulk items with their
* lifecycle stage. Shares the ['container-items', bookingId] cache with
* ContainerItemsModal, so expanding after using the modal is instant.
*/
function BookingItemsExpansion({
bookingId,
colSpan,
bulkFallback,
}: {
bookingId: string | null;
colSpan: number;
bulkFallback?: string;
}) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['container-items', bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: Boolean(bookingId),
});
return (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="sm">
<Loader size="xs" />
</Group>
) : items.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
{bulkFallback ?? 'No container units recorded on this booking.'}
</Text>
) : (
<Table verticalSpacing={4} fz="xs" withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container #</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>GRN</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Text size="xs" fw={600}>{i.containerNumber}</Text>
</Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
{i.stage}
</Badge>
</Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
/** One-click bulk actions are irreversible — make the click deliberate. */
@@ -1295,6 +1372,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
@@ -1365,6 +1443,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={34} />
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
@@ -1389,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
{rows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -1425,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Button>
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={18}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
);
})}
</Table.Tbody>
@@ -1450,6 +1548,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
);
const qc = useQueryClient();
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
@@ -1571,6 +1670,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={34} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1584,7 +1684,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
@@ -1607,6 +1718,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<InventoryStatusBadge status={r.status as InventoryStatus} />
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={11}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
@@ -1634,6 +1753,7 @@ function LoadedExportTab({
api.warehouses.loadedExport.queryOptions({ enabled }),
);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const bulkDispatch = useMutation(
api.warehouses.bulkDispatchExport.mutationOptions(),
);
@@ -1743,6 +1863,7 @@ function LoadedExportTab({
/>
</Table.Th>
)}
<Table.Th w={34} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1755,7 +1876,8 @@ function LoadedExportTab({
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
{dispatchable && (
<Table.Td>
<Checkbox
@@ -1765,6 +1887,16 @@ function LoadedExportTab({
/>
</Table.Td>
)}
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
@@ -1782,6 +1914,14 @@ function LoadedExportTab({
<InventoryStatusBadge status={r.status as InventoryStatus} />
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={11}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
@@ -2221,6 +2361,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
@@ -2383,6 +2524,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={34} />
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
@@ -2408,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Thead>
<Table.Tbody>
{rows.map((r: ImportUnloadedItem) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -2534,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Group>
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={16}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>