mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Warehouse receiving for export and GRN generation
This commit is contained in:
@@ -18,16 +18,20 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
ImportUnloadedItem,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -49,6 +53,106 @@ interface Location {
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
interface TruckEntranceFormState {
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
driverName: string;
|
||||
driverPhone: string;
|
||||
driverLicenseNumber: string;
|
||||
truckType: string;
|
||||
entranceTareWeightKg: number | '';
|
||||
exitTareWeightKg: number | '';
|
||||
}
|
||||
|
||||
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
truckPlateNumber: '',
|
||||
trailerPlateNumber: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
driverLicenseNumber: '',
|
||||
truckType: '',
|
||||
entranceTareWeightKg: '',
|
||||
exitTareWeightKg: '',
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
driverName: form.driverName.trim(),
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
truckType: form.truckType.trim() || undefined,
|
||||
entranceTareWeightKg: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
|
||||
});
|
||||
|
||||
function TruckEntranceFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
required
|
||||
value={value.truckPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={value.trailerPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver name"
|
||||
required
|
||||
value={value.driverName}
|
||||
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
required
|
||||
value={value.driverPhone}
|
||||
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver license number"
|
||||
value={value.driverLicenseNumber}
|
||||
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Truck type"
|
||||
value={value.truckType}
|
||||
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Entrance tare weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.entranceTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */
|
||||
function LocationSelects({
|
||||
value,
|
||||
@@ -140,20 +244,105 @@ function EligibleTab({
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: allRows = [], isLoading } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({ enabled }),
|
||||
);
|
||||
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
||||
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
||||
const requestFirstMile = useMutation({
|
||||
mutationFn: (reference: string) => firstMileService.accept(reference),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [statusTab, setStatusTab] = useState('ALL');
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
|
||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const canReceiveBooking = (row: EligibleBooking) =>
|
||||
!(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT');
|
||||
const statusOptions = useMemo(() => {
|
||||
const base = [{ value: 'ALL', label: 'All bookings' }];
|
||||
if (direction === 'EXPORT') {
|
||||
return [
|
||||
...base,
|
||||
{ value: 'DIRECT', label: 'Direct truck' },
|
||||
{ value: 'FIRST_MILE', label: 'First mile' },
|
||||
{ value: 'FIRST_MILE_READY', label: 'First mile arrived' },
|
||||
{ value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...base,
|
||||
{ value: 'READY_TO_RECEIVE', label: 'Ready to receive' },
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
];
|
||||
}, [direction]);
|
||||
const statusFilteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
switch (statusTab) {
|
||||
case 'DIRECT':
|
||||
return !row.hasFirstMile;
|
||||
case 'FIRST_MILE':
|
||||
return row.hasFirstMile;
|
||||
case 'FIRST_MILE_READY':
|
||||
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
||||
case 'AWAITING_FIRST_MILE':
|
||||
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
||||
case 'READY_TO_RECEIVE':
|
||||
return canReceiveBooking(row);
|
||||
case 'PAID':
|
||||
return row.paymentStatus === 'PAID';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
[rows, statusTab],
|
||||
);
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
statusOptions.map((option) => [
|
||||
option.value,
|
||||
rows.filter((row) => {
|
||||
switch (option.value) {
|
||||
case 'DIRECT':
|
||||
return !row.hasFirstMile;
|
||||
case 'FIRST_MILE':
|
||||
return row.hasFirstMile;
|
||||
case 'FIRST_MILE_READY':
|
||||
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
||||
case 'AWAITING_FIRST_MILE':
|
||||
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
||||
case 'READY_TO_RECEIVE':
|
||||
return canReceiveBooking(row);
|
||||
case 'PAID':
|
||||
return row.paymentStatus === 'PAID';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).length,
|
||||
]),
|
||||
),
|
||||
[rows, statusOptions],
|
||||
);
|
||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -161,7 +350,7 @@ function EligibleTab({
|
||||
return next;
|
||||
});
|
||||
|
||||
const receive = async (bookingIds: string[]) => {
|
||||
const openTruckReceive = (bookingIds: string[]) => {
|
||||
if (!locationReady) {
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
||||
return;
|
||||
@@ -170,13 +359,41 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'Select at least one booking' });
|
||||
return;
|
||||
}
|
||||
const allowedIds = new Set(selectableRows.map((row) => row.id));
|
||||
const filteredIds = bookingIds.filter((id) => allowedIds.has(id));
|
||||
if (filteredIds.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||
return;
|
||||
}
|
||||
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setTruckForm({
|
||||
...emptyTruckEntrance(),
|
||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
||||
});
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
const receive = async () => {
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds });
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds: pendingReceiveIds,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
@@ -198,9 +415,19 @@ function EligibleTab({
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Tabs value={statusTab} onChange={(v) => setStatusTab(v ?? 'ALL')}>
|
||||
<Tabs.List>
|
||||
{statusOptions.map((option) => (
|
||||
<Tabs.Tab key={option.value} value={option.value}>
|
||||
{option.label} ({statusCounts[option.value] ?? 0})
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b> / {rows.length} eligible
|
||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{direction === 'EXPORT' && (
|
||||
@@ -218,9 +445,9 @@ function EligibleTab({
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
disabled={!locationReady || rows.length === 0}
|
||||
disabled={!locationReady || selectableRows.length === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive(rows.map((r) => r.id))}
|
||||
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
|
||||
>
|
||||
Receive All Eligible
|
||||
</Button>
|
||||
@@ -228,7 +455,7 @@ function EligibleTab({
|
||||
size="compact-sm"
|
||||
disabled={!locationReady || selected.size === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive([...selected])}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
>
|
||||
Receive Selected
|
||||
</Button>
|
||||
@@ -245,7 +472,7 @@ function EligibleTab({
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
) : statusFilteredRows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No eligible PAID {direction.toLowerCase()} bookings to receive.
|
||||
</Text>
|
||||
@@ -275,16 +502,20 @@ function EligibleTab({
|
||||
<Table.Th>Payment</Table.Th>
|
||||
<Table.Th>Current Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
{direction === 'EXPORT' && <Table.Th>First Mile</Table.Th>}
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
{statusFilteredRows.map((r) => {
|
||||
const canReceive = canReceiveBooking(r);
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.reference}`}
|
||||
checked={selected.has(r.id)}
|
||||
disabled={!canReceive}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
@@ -319,23 +550,73 @@ function EligibleTab({
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>—</Table.Td>
|
||||
{direction === 'EXPORT' && (
|
||||
<Table.Td>
|
||||
{r.hasFirstMile ? (
|
||||
<Stack gap={2}>
|
||||
<Badge
|
||||
color={r.firstMileStatus === 'RECEIVED_TO_PORT' ? 'green' : r.firstMileRequestId ? 'blue' : 'orange'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{r.firstMileStatus ?? 'Request needed'}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Badge color="gray" variant="light" size="sm">Direct arrival</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
disabled={!locationReady}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => receive([r.id])}
|
||||
>
|
||||
Receive
|
||||
</Button>
|
||||
{direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={requestFirstMile.isPending}
|
||||
onClick={() => requestFirstMile.mutate(r.reference)}
|
||||
>
|
||||
Request First Mile
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
disabled={!locationReady || !canReceive}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive' : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Receive and Generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1203,11 +1484,13 @@ function SingleBookingReceiveModal({
|
||||
volume: '',
|
||||
notes: '',
|
||||
});
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setSelectedBooking(bookingId ?? '');
|
||||
setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' });
|
||||
setTruckForm(emptyTruckEntrance());
|
||||
}
|
||||
}, [opened, bookingId]);
|
||||
|
||||
@@ -1226,6 +1509,10 @@ function SingleBookingReceiveModal({
|
||||
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
|
||||
return;
|
||||
}
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
const payload: ReceiveInventoryPayload = {
|
||||
bookingId: selectedBooking.trim(),
|
||||
warehouseId: form.warehouseId,
|
||||
@@ -1235,6 +1522,7 @@ function SingleBookingReceiveModal({
|
||||
weight: Number(form.weight),
|
||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||
notes: form.notes.trim() || undefined,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
};
|
||||
try {
|
||||
await receiveMutation.mutateAsync(payload);
|
||||
@@ -1281,6 +1569,8 @@ function SingleBookingReceiveModal({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional notes"
|
||||
@@ -1298,7 +1588,7 @@ function SingleBookingReceiveModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={receiveMutation.isPending}>
|
||||
Receive inventory
|
||||
Receive inventory and generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user