Import operation gate pass

This commit is contained in:
hagiye
2026-06-27 21:04:27 +03:00
parent 3441ee4a0a
commit fee4365b70
37 changed files with 3084 additions and 116 deletions

View File

@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from "@/constants/apiConfig";
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo {
id: string;

View File

@@ -88,9 +88,15 @@ interface TruckEntranceFormState {
interface LockedTruckEntranceFields {
ownerName?: boolean;
consigneeDetails?: boolean;
tin?: boolean;
edrDigitalBookingId?: boolean;
customerPhone?: boolean;
assignedEquipmentNumber?: boolean;
itemDescription?: boolean;
packagingType?: boolean;
unitCount?: boolean;
grossWeightKg?: boolean;
}
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
@@ -129,23 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
});
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
ownerName: form.ownerName.trim() || undefined,
consigneeDetails: form.consigneeDetails.trim() || undefined,
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
tin: form.tin.trim() || undefined,
customerPhone: form.customerPhone.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
@@ -221,9 +217,15 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
},
lockedFields: {
ownerName: Boolean(ownerName),
consigneeDetails: Boolean(consigneeDetails),
tin: Boolean(tin),
edrDigitalBookingId: Boolean(edrDigitalBookingId),
customerPhone: Boolean(customerPhone),
assignedEquipmentNumber: Boolean(assignedEquipmentNumber),
itemDescription: Boolean(itemDescription),
packagingType: Boolean(packagingType),
unitCount: unitCount !== '',
grossWeightKg: grossWeightKg !== '',
},
packagingFreightType,
};
@@ -292,6 +294,7 @@ function TruckEntranceFields({
<TextInput
label="Consignee details"
value={value.consigneeDetails}
readOnly={lockedFields?.consigneeDetails}
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
/>
</Group>
@@ -334,6 +337,7 @@ function TruckEntranceFields({
<TextInput
label="Assigned wagon / container number"
value={value.assignedEquipmentNumber}
readOnly={lockedFields?.assignedEquipmentNumber}
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
/>
<TextInput
@@ -413,6 +417,7 @@ function TruckEntranceFields({
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
@@ -422,6 +427,7 @@ function TruckEntranceFields({
data={packagingOptions}
clearable
searchable
readOnly={lockedFields?.packagingType}
value={value.packagingType}
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
/>
@@ -429,6 +435,7 @@ function TruckEntranceFields({
label={quantityLabel}
min={0}
value={value.unitCount}
readOnly={lockedFields?.unitCount}
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
@@ -437,6 +444,7 @@ function TruckEntranceFields({
label="Gross weight (kg)"
min={0}
value={value.grossWeightKg}
readOnly={lockedFields?.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
@@ -676,6 +684,14 @@ function EligibleTab({
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const pendingReceiveRows = useMemo(
() =>
pendingReceiveIds
.map((id) => rows.find((item) => item.id === id))
.filter(Boolean) as EligibleBooking[],
[pendingReceiveIds, rows],
);
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -686,6 +702,29 @@ function EligibleTab({
return next;
});
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(truckEntrance ? { truckEntrance } : {}),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
const openTruckReceive = (bookingIds: string[]) => {
if (!locationReady) {
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
@@ -704,6 +743,10 @@ function EligibleTab({
const selectedRows = filteredIds
.map((id) => rows.find((item) => item.id === id))
.filter(Boolean) as EligibleBooking[];
if (direction === 'IMPORT') {
void receiveBookings(filteredIds);
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
setPendingReceiveIds(filteredIds);
setTruckForm(form);
@@ -717,26 +760,7 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds: pendingReceiveIds,
truckEntrance: toTruckEntrancePayload(truckForm),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
};
const loadPassedExport = async () => {
@@ -940,11 +964,21 @@ function EligibleTab({
</Table.ScrollContainer>
)}
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Export Truck Arrival / First Mile Receive Form"
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>
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
<Text size="sm">
{pendingHasFirstMile
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -956,7 +990,7 @@ function EligibleTab({
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Receive and Generate GRN
Register Arrival & Generate GRN
</Button>
</Group>
</Stack>
@@ -965,6 +999,161 @@ function EligibleTab({
);
}
/** Export received items awaiting inspection before loading. */
function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.receivedExport.queryOptions({ enabled }),
);
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {pendingRows.length} received
</Text>
<Button
size="compact-sm"
color="indigo"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No received export items awaiting inspection.
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={toggleAll}
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Table.Tr key={r.id}>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
checked={selected.has(r.id)}
disabled={!selectable}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
<Table.Td>
{r.origin || r.destination ? `${r.origin ?? '?'}${r.destination ?? '?'}` : '—'}
</Table.Td>
<Table.Td>
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
{r.inspectionStatus ?? 'PENDING'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color="blue" variant="light" size="sm">
{r.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<InspectionReportModal
inventoryId={inspectId}
opened={Boolean(inspectId)}
onClose={() => setInspectId(null)}
/>
</Stack>
);
}
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
@@ -1765,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
<Tabs defaultValue="receive-queue" mt="xs">
<Tabs.List>
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
<Tabs.Tab value="received">Received</Tabs.Tab>
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
@@ -1773,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
<Tabs.Panel value="receive-queue">
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="received">
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="ready-to-load">
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
@@ -1849,10 +2042,6 @@ function SingleBookingReceiveModal({
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
return;
}
if (form.quantity === '' || form.weight === '') {
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;
@@ -1862,8 +2051,8 @@ function SingleBookingReceiveModal({
warehouseId: form.warehouseId,
yardId: form.yardId,
zoneId: form.zoneId,
quantity: Number(form.quantity),
weight: Number(form.weight),
quantity: 0,
weight: 0,
volume: form.volume === '' ? undefined : Number(form.volume),
notes: form.notes.trim() || undefined,
truckEntrance: toTruckEntrancePayload(truckForm),
@@ -1889,21 +2078,13 @@ function SingleBookingReceiveModal({
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
</Text>
</Alert>
<Group grow>
<NumberInput
label="Quantity"
required
min={0}
value={form.quantity}
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Weight (kg)"
required
min={0}
value={form.weight}
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Volume (m³)"
placeholder="Optional"