mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1022 from Tria-plc/edrmiles
feat: repeat truck column labels inside each expanded booking
This commit is contained in:
@@ -4,7 +4,9 @@ import {
|
|||||||
WAREHOUSE_ZONE_TYPES,
|
WAREHOUSE_ZONE_TYPES,
|
||||||
WAREHOUSE_STATUSES,
|
WAREHOUSE_STATUSES,
|
||||||
INVENTORY_STATUSES,
|
INVENTORY_STATUSES,
|
||||||
|
type ImportUnloadedItem,
|
||||||
type Warehouse,
|
type Warehouse,
|
||||||
|
type WarehouseInventoryItem,
|
||||||
type WarehouseYard,
|
type WarehouseYard,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
@@ -120,6 +122,44 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
|||||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unloaded-queue row seen as the inventory item `ReleaseOrderModal` expects.
|
||||||
|
* Both truck-arrival openers (last mile, import trucks) work off queue rows.
|
||||||
|
*/
|
||||||
|
export const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||||
|
({
|
||||||
|
id: row.id,
|
||||||
|
bookingId: row.bookingId,
|
||||||
|
quantity: 1,
|
||||||
|
weight: Number(row.weight) || 0,
|
||||||
|
grnNumber: row.grnNumber,
|
||||||
|
status: row.currentStatus,
|
||||||
|
arrivedAt: row.arrivalTime,
|
||||||
|
unloadedAt: row.arrivalTime,
|
||||||
|
inspectionStatus: row.inspectionStatus,
|
||||||
|
releaseDate: row.releaseDate,
|
||||||
|
releaseOrderReference: row.releaseOrderReference,
|
||||||
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
|
deliveredAt: row.deliveredAt,
|
||||||
|
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||||
|
// details captured at arrival (plate, driver, tare, gate-in).
|
||||||
|
notes: row.notes,
|
||||||
|
booking: row.bookingId
|
||||||
|
? {
|
||||||
|
id: row.bookingId,
|
||||||
|
reference: row.bookingReference ?? row.bookingId,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||||
|
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||||
|
customerTruckDriverName: row.customerTruckDriverName,
|
||||||
|
customerTruckType: row.customerTruckType,
|
||||||
|
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||||
|
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}) as unknown as WarehouseInventoryItem;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { toReleaseInventoryItem } from "@/components/warehouses/options";
|
||||||
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
@@ -299,40 +300,6 @@ const requestedDate = (r: LastMileRecord) => {
|
|||||||
const serviceTypeName = (r: LastMileRecord) =>
|
const serviceTypeName = (r: LastMileRecord) =>
|
||||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||||
|
|
||||||
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
|
||||||
({
|
|
||||||
id: row.id,
|
|
||||||
bookingId: row.bookingId,
|
|
||||||
quantity: 1,
|
|
||||||
weight: Number(row.weight) || 0,
|
|
||||||
grnNumber: row.grnNumber,
|
|
||||||
status: row.currentStatus,
|
|
||||||
arrivedAt: row.arrivalTime,
|
|
||||||
unloadedAt: row.arrivalTime,
|
|
||||||
inspectionStatus: row.inspectionStatus,
|
|
||||||
releaseDate: row.releaseDate,
|
|
||||||
releaseOrderReference: row.releaseOrderReference,
|
|
||||||
handoverDocumentReference: row.handoverDocumentReference,
|
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
|
||||||
deliveredAt: row.deliveredAt,
|
|
||||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
|
||||||
// details captured at arrival (plate, driver, tare, gate-in).
|
|
||||||
notes: row.notes,
|
|
||||||
booking: row.bookingId
|
|
||||||
? {
|
|
||||||
id: row.bookingId,
|
|
||||||
reference: row.bookingReference ?? row.bookingId,
|
|
||||||
tradeDirection: "IMPORT",
|
|
||||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
|
||||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
|
||||||
customerTruckDriverName: row.customerTruckDriverName,
|
|
||||||
customerTruckType: row.customerTruckType,
|
|
||||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
|
||||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}) as unknown as WarehouseInventoryItem;
|
|
||||||
|
|
||||||
const releasePrefillFromLastMile = (
|
const releasePrefillFromLastMile = (
|
||||||
record: LastMileRecord,
|
record: LastMileRecord,
|
||||||
row?: ImportUnloadedItem | null,
|
row?: ImportUnloadedItem | null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -27,14 +28,22 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|||||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
import {
|
||||||
|
ReleaseOrderModal,
|
||||||
|
type ReleaseOrderTruckPrefill,
|
||||||
|
} from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import {
|
||||||
|
extractDownloadErrorMessage,
|
||||||
|
formatNumber,
|
||||||
|
toReleaseInventoryItem,
|
||||||
|
} from "@/components/warehouses/options";
|
||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import { useListControls } from "@/hooks/useListControls";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { lastMileService } from "@/services/last-mile.service";
|
import { lastMileService } from "@/services/last-mile.service";
|
||||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
||||||
import type { ImportUnloadedItem } from "@/types/warehouse";
|
import type { ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
||||||
@@ -53,6 +62,20 @@ import type { ImportUnloadedItem } from "@/types/warehouse";
|
|||||||
|
|
||||||
const COLS = 11;
|
const COLS = 11;
|
||||||
|
|
||||||
|
/** Truck columns — the table head, and repeated inside each expanded booking. */
|
||||||
|
const TRUCK_COLUMNS = [
|
||||||
|
"Plate",
|
||||||
|
"Type",
|
||||||
|
"Containers",
|
||||||
|
"Truck Arrival",
|
||||||
|
"Truck Leaving",
|
||||||
|
"Weight",
|
||||||
|
"Demurrage",
|
||||||
|
"Storage",
|
||||||
|
"Detention",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const money = (amount: number, currency: string) =>
|
const money = (amount: number, currency: string) =>
|
||||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||||
|
|
||||||
@@ -109,6 +132,8 @@ interface TruckRow {
|
|||||||
vehicleId: string | null;
|
vehicleId: string | null;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
departedAt: string | null;
|
departedAt: string | null;
|
||||||
|
/** Identity handed to the release modal so it opens on THIS truck. */
|
||||||
|
prefill: ReleaseOrderTruckPrefill;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,10 +142,17 @@ interface TruckRow {
|
|||||||
*/
|
*/
|
||||||
function TruckRows({ group }: { group: BookingGroup }) {
|
function TruckRows({ group }: { group: BookingGroup }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||||
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
||||||
|
// The truck whose arrival/exit weighing is open — kept in state so the prefill
|
||||||
|
// object stays referentially stable while the modal is up.
|
||||||
|
const [release, setRelease] = useState<{
|
||||||
|
item: WarehouseInventoryItem;
|
||||||
|
prefill: ReleaseOrderTruckPrefill;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const edrQuery = useQuery({
|
const edrQuery = useQuery({
|
||||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||||
@@ -194,6 +226,15 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
vehicleId: t.vehicleId,
|
vehicleId: t.vehicleId,
|
||||||
arrivedAt: t.arrivedAt,
|
arrivedAt: t.arrivedAt,
|
||||||
departedAt: t.departedAt,
|
departedAt: t.departedAt,
|
||||||
|
prefill: {
|
||||||
|
truckPlateNumber: t.truckPlateNumber,
|
||||||
|
trailerPlateNumber: t.trailerPlateNumber,
|
||||||
|
driverName: t.driverName,
|
||||||
|
driverLicense: t.driverLicense,
|
||||||
|
driverPhone: t.driverPhone,
|
||||||
|
truckType: t.truckType,
|
||||||
|
containerNumber: t.containerNumber,
|
||||||
|
},
|
||||||
...costsFor(containers),
|
...costsFor(containers),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -208,11 +249,39 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
arrivedAt: t.arrivedAt ?? null,
|
arrivedAt: t.arrivedAt ?? null,
|
||||||
departedAt: t.departedAt ?? null,
|
departedAt: t.departedAt ?? null,
|
||||||
|
prefill: {
|
||||||
|
truckPlateNumber: t.plateNumber,
|
||||||
|
driverName: t.driverName,
|
||||||
|
truckType: t.truckType,
|
||||||
|
containerNumber: containers.join(", "),
|
||||||
|
},
|
||||||
...costsFor(containers),
|
...costsFor(containers),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrival and leaving are one form per truck: the modal picks the step from
|
||||||
|
* that plate's own saved weighing block, so both menu items open it the same
|
||||||
|
* way. The booking-level opener (inventory workbench) stays as it was.
|
||||||
|
*/
|
||||||
|
const openRelease = (t: TruckRow) => {
|
||||||
|
const row = group.rows.find((r) => r.id === t.inventoryIds[0]) ?? group.rows[0];
|
||||||
|
if (!row) return;
|
||||||
|
setRelease({ item: toReleaseInventoryItem(row), prefill: t.prefill });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRelease = () => {
|
||||||
|
setRelease(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["booking-edr-trucks", group.bookingId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["booking-customer-trucks", group.bookingId] });
|
||||||
|
// The saved weighing block lives in the inventory row's notes — refetch the
|
||||||
|
// queue or the next open would still show the truck as never arrived.
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.warehouses.importUnloadedQueue.queryOptions({}).queryKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openDocument = async (
|
const openDocument = async (
|
||||||
kind: "release" | "handover",
|
kind: "release" | "handover",
|
||||||
inventoryId: string,
|
inventoryId: string,
|
||||||
@@ -267,6 +336,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* The booking row sits between the head and these trucks, so repeat the
|
||||||
|
column labels — otherwise an expanded booking reads as unlabelled. */}
|
||||||
|
<Table.Tr bg="var(--mantine-color-gray-1)">
|
||||||
|
<Table.Td />
|
||||||
|
{TRUCK_COLUMNS.map((label) => (
|
||||||
|
<Table.Td key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
{trucks.map((t, idx) => {
|
{trucks.map((t, idx) => {
|
||||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||||
const primaryId = t.inventoryIds[0] ?? null;
|
const primaryId = t.inventoryIds[0] ?? null;
|
||||||
@@ -337,6 +418,21 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Truck size={14} />}
|
||||||
|
disabled={!primaryId}
|
||||||
|
onClick={() => openRelease(t)}
|
||||||
|
>
|
||||||
|
Truck Arrival
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Truck size={14} />}
|
||||||
|
disabled={!primaryId}
|
||||||
|
onClick={() => openRelease(t)}
|
||||||
|
>
|
||||||
|
Truck Leaving
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<FileText size={14} />}
|
leftSection={<FileText size={14} />}
|
||||||
disabled={!primaryId}
|
disabled={!primaryId}
|
||||||
@@ -388,6 +484,12 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
onClose={() => setInspectId(null)}
|
onClose={() => setInspectId(null)}
|
||||||
inventoryId={inspectId}
|
inventoryId={inspectId}
|
||||||
/>
|
/>
|
||||||
|
<ReleaseOrderModal
|
||||||
|
opened={Boolean(release)}
|
||||||
|
onClose={closeRelease}
|
||||||
|
item={release?.item ?? null}
|
||||||
|
truckPrefill={release?.prefill ?? null}
|
||||||
|
/>
|
||||||
{isEdr && (
|
{isEdr && (
|
||||||
<>
|
<>
|
||||||
<TruckDetentionModal
|
<TruckDetentionModal
|
||||||
@@ -459,16 +561,11 @@ export default function ImportTrucksPage() {
|
|||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th w={40} />
|
<Table.Th w={40} />
|
||||||
<Table.Th>Plate</Table.Th>
|
{TRUCK_COLUMNS.map((label) => (
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||||
<Table.Th>Containers</Table.Th>
|
{label}
|
||||||
<Table.Th>Truck Arrival</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Truck Leaving</Table.Th>
|
))}
|
||||||
<Table.Th>Weight</Table.Th>
|
|
||||||
<Table.Th>Demurrage</Table.Th>
|
|
||||||
<Table.Th>Storage</Table.Th>
|
|
||||||
<Table.Th>Detention</Table.Th>
|
|
||||||
<Table.Th ta="right">Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user