Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
Marshal
2026-07-02 13:29:10 +00:00
71 changed files with 5165 additions and 672 deletions

View File

@@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
{(fee.tiers ?? []).map((tier) => (
<Row
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
label={`Days ${tier.appliedFromDay}-${tier.appliedToDay}`}
value={`${tier.days} x ${money(tier.ratePerDay, fee.currency)} = ${money(tier.amount, fee.currency)}`}
/>
))}
</Stack>
)}
</Card>

View File

@@ -41,7 +41,7 @@ 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 { useInventoryInquiry } from '@/hooks/useWarehouses';
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
@@ -54,7 +54,10 @@ import type {
ReadyToLoadRow,
ReceiveInventoryPayload,
TruckEntrancePayload,
Warehouse,
WarehouseInventoryItem,
WarehouseYard,
WarehouseZone,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
@@ -70,12 +73,17 @@ import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions }
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
type ImportUnloadAssignmentDraft = Partial<Omit<ImportUnloadAssignment, 'bookingId'>>;
interface ReceiveInventoryModalProps {
opened: boolean;
onClose: () => void;
/** When supplied the modal locks to a single booking (legacy single-receive). */
bookingId?: string;
bookingLabel?: string;
mode?: 'single' | 'bulk';
direction?: WarehouseFlowDirection;
onReceived?: () => void;
}
@@ -142,6 +150,7 @@ interface TruckEntranceFormState {
packagingType: string;
unitCount: number | '';
grossWeightKg: number | '';
weighingRequired: boolean | null;
netWeightKg: number | '';
volumeDimensions: string;
conditionAtReceipt: string;
@@ -163,11 +172,17 @@ interface LockedTruckEntranceFields {
tin?: boolean;
edrDigitalBookingId?: boolean;
customerPhone?: boolean;
truckPlateNumber?: boolean;
trailerPlateNumber?: boolean;
assignedEquipmentNumber?: boolean;
itemDescription?: boolean;
packagingType?: boolean;
unitCount?: boolean;
grossWeightKg?: boolean;
driverName?: boolean;
driverPhone?: boolean;
driverLicenseNumber?: boolean;
truckType?: boolean;
}
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
@@ -190,6 +205,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
packagingType: '',
unitCount: '',
grossWeightKg: '',
weighingRequired: null,
netWeightKg: '',
volumeDimensions: '',
conditionAtReceipt: '',
@@ -206,13 +222,24 @@ 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),
weighingRequired: form.weighingRequired ?? undefined,
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
@@ -222,8 +249,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
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),
entranceTareWeightKg:
form.entranceTareWeightKg === ''
? undefined
: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
@@ -242,22 +272,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
const assignedEquipmentNumber = commonNonEmptyValue(
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
);
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
const truckPlateNumber = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber),
);
const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber));
const driverName = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName),
);
const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone));
const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber));
const truckType = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
);
const edrDigitalBookingId =
bookings.length === 1
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
const unitCount =
bookings.length === 1 && bookings[0]?.containerQuantity != null
? Number(bookings[0].containerQuantity)
: '';
const grossWeightKg =
bookings.length === 1 && bookings[0]?.weight != null
? Number(bookings[0].weight)
: '';
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
const packagingFreightType =
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
@@ -278,13 +317,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription,
packagingType,
unitCount,
grossWeightKg,
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
driverName: firstMileBooking?.firstMileDriverName ?? '',
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
truckType: firstMileBooking?.firstMileTruckType ?? '',
grossWeightKg: '',
truckPlateNumber,
trailerPlateNumber,
driverName,
driverPhone,
driverLicenseNumber,
truckType,
},
lockedFields: {
ownerName: Boolean(ownerName),
@@ -296,7 +335,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription: Boolean(itemDescription),
packagingType: Boolean(packagingType),
unitCount: unitCount !== '',
grossWeightKg: grossWeightKg !== '',
grossWeightKg: false,
truckPlateNumber: Boolean(truckPlateNumber),
trailerPlateNumber: Boolean(trailerPlateNumber),
driverName: Boolean(driverName),
driverPhone: Boolean(driverPhone),
driverLicenseNumber: Boolean(driverLicenseNumber),
truckType: Boolean(truckType),
},
packagingFreightType,
};
@@ -338,11 +383,13 @@ function TruckEntranceFields({
onChange,
lockedFields,
packagingFreightType = 'MIXED',
allowTruckWeighing = true,
}: {
value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void;
lockedFields?: LockedTruckEntranceFields;
packagingFreightType?: PackagingFreightType;
allowTruckWeighing?: boolean;
}) {
const packagingOptions = packagingOptionsFor(packagingFreightType);
const quantityLabel =
@@ -396,11 +443,13 @@ function TruckEntranceFields({
label="Truck plate number"
required
value={value.truckPlateNumber}
readOnly={lockedFields?.truckPlateNumber}
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
/>
<TextInput
label="Trailer plate number"
value={value.trailerPlateNumber}
readOnly={lockedFields?.trailerPlateNumber}
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
/>
</Group>
@@ -422,12 +471,14 @@ function TruckEntranceFields({
label="Driver name"
required
value={value.driverName}
readOnly={lockedFields?.driverName}
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
/>
<TextInput
label="Driver phone"
required
value={value.driverPhone}
readOnly={lockedFields?.driverPhone}
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
/>
</Group>
@@ -435,29 +486,61 @@ function TruckEntranceFields({
<TextInput
label="Driver license number"
value={value.driverLicenseNumber}
readOnly={lockedFields?.driverLicenseNumber}
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
/>
<TextInput
label="Truck type"
value={value.truckType}
readOnly={lockedFields?.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>
{allowTruckWeighing ? (
<>
<Select
label="Weighing"
required
data={[
{ value: 'YES', label: 'Yes' },
{ value: 'NO', label: 'No' },
]}
value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
onChange={(next) =>
onChange({
...value,
weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
grossWeightKg: next === 'YES' ? value.grossWeightKg : '',
exitTareWeightKg: next === 'YES' ? value.exitTareWeightKg : '',
})
}
/>
{value.weighingRequired && (
<Group grow>
<NumberInput
label="Gross weight (kg)"
required
min={0}
value={value.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
required
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
)}
</>
) : (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck weighing is not required for a received first-mile arrival. The GRN uses the booking weight.
</Text>
</Alert>
)}
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
@@ -510,21 +593,12 @@ function TruckEntranceFields({
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
<Group grow>
<NumberInput
label="Gross weight (kg)"
min={0}
value={value.grossWeightKg}
readOnly={lockedFields?.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
<TextInput
label="Volume / dimensions"
value={value.volumeDimensions}
@@ -650,11 +724,15 @@ function EligibleTab({
location,
enabled,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: {
direction: 'IMPORT' | 'EXPORT';
location: Location;
enabled: boolean;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}) {
const { toast } = useToast();
const qc = useQueryClient();
@@ -664,7 +742,15 @@ function EligibleTab({
enabled,
}),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const rows = useMemo(
() =>
allRows.filter(
(r) =>
r.direction === direction &&
(!focusedBookingId || r.id === focusedBookingId),
),
[allRows, direction, focusedBookingId],
);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
@@ -766,6 +852,8 @@ function EligibleTab({
[pendingReceiveIds, rows],
);
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -788,6 +876,18 @@ function EligibleTab({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
}
}
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
@@ -822,32 +922,65 @@ function EligibleTab({
void receiveBookings(filteredIds);
return;
}
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
toast({
variant: 'destructive',
title: 'Receive separately',
description: 'First-mile arrivals and customer-truck arrivals use different truck evidence. Select one group at a time.',
});
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const usesFirstMile = selectedRows.length > 0 && selectedRows.every((row) => row.hasFirstMile);
const usesCustomerAssignedTruck =
selectedRows.length > 0 &&
selectedRows.every((row) => !row.hasFirstMile && Boolean(row.customerTruckAssignedAt));
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
: {
...form,
};
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
assignedEquipmentNumber: usesCustomerAssignedTruck
? lockedFields.assignedEquipmentNumber
: lockedFields.assignedEquipmentNumber,
truckPlateNumber: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckPlateNumber,
trailerPlateNumber: usesFirstMile && lockedFields.trailerPlateNumber,
driverName: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.driverName,
driverPhone: usesFirstMile && lockedFields.driverPhone,
driverLicenseNumber: usesFirstMile && lockedFields.driverLicenseNumber,
truckType: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckType,
});
setPackagingFreightType(nextPackagingFreightType);
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' });
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
toast({ variant: 'destructive', title: 'Truck and driver information are required' });
return;
}
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
return;
}
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
@@ -906,7 +1039,9 @@ function EligibleTab({
</Group>
) : statusFilteredRows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No eligible PAID {direction.toLowerCase()} bookings to receive.
{focusedBookingLabel
? `${focusedBookingLabel} is not eligible for warehouse receiving yet.`
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
@@ -1053,8 +1188,8 @@ function EligibleTab({
<Stack gap="md">
<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.'
{pendingUsesFirstMile
? 'Received first-mile truck and driver details are pulled from the first-mile record. GRN uses booking cargo, quantity and weight.'
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
@@ -1109,6 +1244,7 @@ function EligibleTab({
onChange={setTruckForm}
lockedFields={lockedTruckFields}
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
@@ -1611,14 +1747,59 @@ function LoadedExportTab({
);
}
/** Assigned bookings/items for an arrived import train (read-only detail view). */
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED';
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
function ImportTrainDetailTable({
train,
warehouses,
yards,
zones,
assignments,
onAssignmentChange,
onReadyChange,
}: {
train: ImportTrain;
warehouses: Warehouse[];
yards: WarehouseYard[];
zones: WarehouseZone[];
assignments: Record<string, ImportUnloadAssignmentDraft>;
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
onReadyChange: (ready: boolean) => void;
}) {
const { data: items = [], isLoading } = useQuery(
api.warehouses.importTrainItems.queryOptions({
input: { scheduleId: train.scheduleId },
enabled: Boolean(train.scheduleId),
}),
);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
);
useEffect(() => {
const pending = items.filter(isImportUnloadPending);
onReadyChange(
pending.length > 0 &&
pending.every((item) => {
const draft = assignments[item.bookingId];
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
}),
);
}, [assignments, items]);
if (isLoading) {
return (
@@ -1648,6 +1829,9 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Last Mile</Table.Th>
@@ -1655,7 +1839,18 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((it: ImportTrainItem) => (
{items.map((it: ImportTrainItem) => {
const draft = assignments[it.bookingId] ?? {};
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isImportUnloadPending(it);
return (
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
<Table.Td>
<Text size="xs" fw={600}>
@@ -1676,6 +1871,41 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
<Table.Td>
<Select
placeholder="Warehouse"
data={warehouseOptions}
value={draft.warehouseId ?? null}
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
searchable
disabled={!pending}
w={210}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
data={yardOptions}
value={draft.yardId ?? null}
onChange={(value) =>
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
}
searchable
disabled={!pending || !draft.warehouseId}
w={190}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
data={zoneOptions}
value={draft.zoneId ?? null}
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
searchable
disabled={!pending || !draft.yardId}
w={190}
/>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
{it.inspectionStatus ?? 'Not inspected'}
@@ -1691,7 +1921,8 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
</Table.Td>
<Table.Td>{it.pickupOption}</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
);
@@ -1715,13 +1946,42 @@ function ImportArriveQueueTab({
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
const { data: warehouses = [], isLoading: warehousesLoading } = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }),
);
const { data: yards = [] } = useAllWarehouseYards();
const { data: zones = [] } = useAllWarehouseZones();
const autoUnloadMutation = useMutation(
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
);
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
Record<string, Record<string, ImportUnloadAssignmentDraft>>
>({});
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
const autoUnload = async (train: ImportTrain) => {
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
)
.map(([bookingId, draft]) => ({
bookingId,
warehouseId: draft.warehouseId,
yardId: draft.yardId,
zoneId: draft.zoneId,
}));
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
toast({
variant: 'destructive',
title: 'Assign locations',
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
});
return;
}
if (isFullyUnloaded(train)) {
toast({
title: 'Already unloaded',
@@ -1732,7 +1992,7 @@ function ImportArriveQueueTab({
setBusyId(train.scheduleId);
try {
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments });
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
const firstReason = r.results.find((item) => item.reason)?.reason;
const extra = [
@@ -1831,7 +2091,7 @@ function ImportArriveQueueTab({
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
onClick={() => autoUnload(t)}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
@@ -1842,7 +2102,25 @@ function ImportArriveQueueTab({
{isOpen && (
<Table.Tr>
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailTable train={t} />
<ImportTrainDetailTable
train={t}
warehouses={warehouses}
yards={yards}
zones={zones}
assignments={assignmentsBySchedule[t.scheduleId] ?? {}}
onAssignmentChange={(bookingId, draft) =>
setAssignmentsBySchedule((current) => ({
...current,
[t.scheduleId]: {
...(current[t.scheduleId] ?? {}),
[bookingId]: draft.warehouseId ? draft : {},
},
}))
}
onReadyChange={(ready) =>
setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready }))
}
/>
</Table.Td>
</Table.Tr>
)}
@@ -1934,6 +2212,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
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;
@@ -2262,6 +2545,8 @@ interface WarehouseFlowWorkbenchProps {
direction?: WarehouseFlowDirection;
enabled?: boolean;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}
function WarehouseQueueTabs<TValue extends string>({
@@ -2481,10 +2766,14 @@ function ExportWarehouseTabs({
enabled,
location,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: {
enabled: boolean;
location: Location;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(
@@ -2543,7 +2832,14 @@ function ExportWarehouseTabs({
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'receive-queue' && (
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
<EligibleTab
direction="EXPORT"
location={location}
enabled={enabled}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
)}
{activeTab === 'received' && (
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
@@ -2568,6 +2864,8 @@ export function WarehouseFlowWorkbench({
direction = 'BOTH',
enabled = true,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: WarehouseFlowWorkbenchProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
@@ -2600,24 +2898,42 @@ export function WarehouseFlowWorkbench({
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
</Tabs.Panel>
<Tabs.Panel value="EXPORT">
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
<ExportWarehouseTabs
enabled={enabled}
location={location}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
</Tabs.Panel>
</Tabs>
) : activeDirection === 'IMPORT' ? (
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
) : (
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
<ExportWarehouseTabs
enabled={enabled}
location={location}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
)}
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
<Stack gap="md">
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
<WarehouseFlowWorkbench
enabled={opened}
direction={direction}
onChanged={onReceived}
focusedBookingId={bookingId}
focusedBookingLabel={bookingLabel}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
@@ -2763,5 +3079,5 @@ function SingleBookingReceiveModal({
export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) {
// Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow.
return props.bookingId ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
return props.bookingId && props.mode !== 'bulk' ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
}

View File

@@ -15,6 +15,17 @@ interface ReleaseOrderModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
truckPrefill?: ReleaseOrderTruckPrefill | null;
}
export interface ReleaseOrderTruckPrefill {
truckPlateNumber?: string | null;
trailerPlateNumber?: string | null;
driverName?: string | null;
driverLicense?: string | null;
driverPhone?: string | null;
truckType?: string | null;
containerNumber?: string | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
@@ -82,6 +93,9 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
@@ -117,7 +131,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
@@ -138,25 +152,34 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => {
if (opened) {
const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item]);
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
@@ -269,7 +292,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -283,7 +306,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={isTruckIdentityLocked}
/>
<TextInput
label="Trailer plate number"
@@ -293,12 +316,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow>
<Stack gap={6}>
@@ -313,7 +336,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>