mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -479,6 +479,7 @@ export const URL_CONSTANTS = {
|
||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
|
||||
@@ -280,7 +280,13 @@ export function useImportTrainItems(scheduleId?: string) {
|
||||
|
||||
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||
export const useAutoUnloadArrivedBookings = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||
useInventoryMutation((payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
warehouseService.autoUnloadArrivedBookings(payload),
|
||||
);
|
||||
|
||||
/** Arrived EXPORT trains at Djibouti-side ports. Read-only. */
|
||||
export function useExportDjiboutiArrivalQueue(enabled = true) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
||||
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -338,6 +340,8 @@ const FirstMilePage = () => {
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
||||
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
||||
@@ -527,6 +531,16 @@ const FirstMilePage = () => {
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const openWarehouseReceive = (record: FirstMileRecord) => {
|
||||
setWarehouseReceiveRecord(record);
|
||||
setWarehouseReceiveOpen(true);
|
||||
};
|
||||
|
||||
const closeWarehouseReceive = () => {
|
||||
setWarehouseReceiveOpen(false);
|
||||
setWarehouseReceiveRecord(null);
|
||||
};
|
||||
|
||||
const openContainerAllocation = (firstMileId: string) => {
|
||||
setContainerAllocationFirstMileId(firstMileId);
|
||||
setContainerAllocationOpen(true);
|
||||
@@ -829,6 +843,7 @@ const FirstMilePage = () => {
|
||||
const assigned = isAssigned(row.original);
|
||||
const nextStatus = NEXT_STATUS[row.original.status];
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -867,6 +882,13 @@ const FirstMilePage = () => {
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
disabled={!canReceiveToWarehouse}
|
||||
onClick={() => openWarehouseReceive(row.original)}
|
||||
>
|
||||
Receive to warehouse
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
@@ -1055,6 +1077,19 @@ const FirstMilePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReceiveInventoryModal
|
||||
opened={warehouseReceiveOpen}
|
||||
onClose={closeWarehouseReceive}
|
||||
mode="bulk"
|
||||
direction="EXPORT"
|
||||
bookingId={warehouseReceiveRecord?.bookingId}
|
||||
bookingLabel={warehouseReceiveRecord ? bookingRef(warehouseReceiveRecord) : undefined}
|
||||
onReceived={() => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
closeWarehouseReceive();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accept Booking modal — step 1: booking list, step 2: details + vehicle */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||
import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -44,8 +44,10 @@ import {
|
||||
lastMileService,
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -110,6 +112,59 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
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,
|
||||
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 = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
driversById?: Map<string, Driver>,
|
||||
): ReleaseOrderTruckPrefill => {
|
||||
const vehicle = record.vehicle;
|
||||
const truckType = [vehicle?.manufacturer, vehicle?.model].filter(Boolean).join(" ").trim();
|
||||
const assignedDriver = vehicle?.assignedDriverId ? driversById?.get(vehicle.assignedDriverId) : undefined;
|
||||
const assignedDriverName = assignedDriver
|
||||
? `${assignedDriver.firstName ?? ""} ${assignedDriver.lastName ?? ""}`.trim()
|
||||
: "";
|
||||
return {
|
||||
truckPlateNumber: vehicle?.powerPlateNo || vehicle?.plateNumber || null,
|
||||
trailerPlateNumber: vehicle?.trailerPlateNo || null,
|
||||
driverName: vehicle?.assignedDriverName || assignedDriverName || null,
|
||||
driverLicense: assignedDriver?.licenseNumber || null,
|
||||
driverPhone: assignedDriver?.phoneNumber || null,
|
||||
truckType: vehicle?.vehicleType || truckType || null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
@@ -325,6 +380,8 @@ const LastMilePage = () => {
|
||||
|
||||
const [allocationOpen, setAllocationOpen] = useState(false);
|
||||
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseTruckPrefill, setReleaseTruckPrefill] = useState<ReleaseOrderTruckPrefill | null>(null);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||
@@ -352,6 +409,27 @@ const LastMilePage = () => {
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
|
||||
const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId);
|
||||
|
||||
const { data: driversData } = useQuery({
|
||||
queryKey: ["drivers", "list", "ACTIVE"],
|
||||
queryFn: async () => {
|
||||
const res = await driversService.getAll({ status: "ACTIVE" });
|
||||
return res.data;
|
||||
},
|
||||
enabled: needsDriverLookup,
|
||||
});
|
||||
|
||||
const driversById = useMemo(
|
||||
() => new Map((driversData ?? []).map((driver) => [driver.id, driver])),
|
||||
[driversData],
|
||||
);
|
||||
|
||||
const { data: pickupReadyRows = [] } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "import-pickup-ready-queue"],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
@@ -533,6 +611,15 @@ const LastMilePage = () => {
|
||||
[records, activeId],
|
||||
);
|
||||
|
||||
const pickupReadyByBooking = useMemo(() => {
|
||||
const map = new Map<string, ImportUnloadedItem>();
|
||||
for (const row of pickupReadyRows) {
|
||||
if (row.bookingId) map.set(row.bookingId, row);
|
||||
if (row.bookingReference) map.set(row.bookingReference, row);
|
||||
}
|
||||
return map;
|
||||
}, [pickupReadyRows]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
@@ -569,6 +656,7 @@ const LastMilePage = () => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return records.filter((r) => {
|
||||
if (!matchesFilter(r)) return false;
|
||||
if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false;
|
||||
if (!term) return true;
|
||||
return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
|
||||
.join(" ")
|
||||
@@ -649,6 +737,37 @@ const LastMilePage = () => {
|
||||
setTripSlipOpen(true);
|
||||
};
|
||||
|
||||
const openTruckArrival = (record: LastMileRecord) => {
|
||||
if (!isAssigned(record)) {
|
||||
toast({
|
||||
title: "Assign a truck first",
|
||||
description: "Truck arrival opens after a last-mile vehicle is assigned.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const row = pickupReadyByBooking.get(record.bookingId) ?? pickupReadyByBooking.get(bookingRef(record));
|
||||
if (!row) {
|
||||
toast({
|
||||
title: "Import inventory is not pickup-ready",
|
||||
description: `${bookingRef(record)} must be unloaded and pass inspection before truck arrival.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row, driversById));
|
||||
setReleaseItem(toReleaseInventoryItem(row));
|
||||
};
|
||||
|
||||
const closeTruckArrival = () => {
|
||||
setReleaseItem(null);
|
||||
setReleaseTruckPrefill(null);
|
||||
void qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
};
|
||||
|
||||
const printTripSlip = () => {
|
||||
if (!tripSlipRecord) return;
|
||||
const win = window.open("", "_blank", "width=820,height=920");
|
||||
@@ -809,6 +928,9 @@ const LastMilePage = () => {
|
||||
const assigned = isAssigned(row.original);
|
||||
const nextStatus = NEXT_STATUS[row.original.status];
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const releaseRow =
|
||||
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
|
||||
const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -840,6 +962,13 @@ const LastMilePage = () => {
|
||||
>
|
||||
Reassign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!assigned}
|
||||
onClick={() => openTruckArrival(row.original)}
|
||||
>
|
||||
{truckArrivalLabel}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={15} />}
|
||||
@@ -869,7 +998,7 @@ const LastMilePage = () => {
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vehicleOptions]);
|
||||
}, [vehicleOptions, pickupReadyByBooking]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -1358,6 +1487,13 @@ const LastMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReleaseOrderModal
|
||||
opened={Boolean(releaseItem)}
|
||||
onClose={closeTruckArrival}
|
||||
item={releaseItem}
|
||||
truckPrefill={releaseTruckPrefill}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -88,6 +90,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const autoPreviewedRef = useRef(false);
|
||||
|
||||
const detailQuery = useQuery(
|
||||
@@ -98,6 +104,42 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const schedule = detailQuery.data;
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
(value ?? "").toUpperCase().includes(token),
|
||||
);
|
||||
const gatepassApplies = Boolean(
|
||||
schedule &&
|
||||
((schedule.direction === "IMPORT" &&
|
||||
isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) ||
|
||||
(schedule.direction === "EXPORT" &&
|
||||
isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))),
|
||||
);
|
||||
const gatepassQuery = useQuery({
|
||||
queryKey: ["train-scheduling", "gatepass", scheduleId],
|
||||
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
|
||||
enabled: Boolean(scheduleId && gatepassApplies),
|
||||
});
|
||||
const secureGatepass = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
|
||||
securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined,
|
||||
reference: gatepassReference.trim() || undefined,
|
||||
fileUrl: gatepassFileUrl.trim() || undefined,
|
||||
notes: gatepassNotes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Gate pass secured" });
|
||||
void gatepassQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Gate pass failed",
|
||||
description: parseError(error, "Could not secure gate pass"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +175,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
if (!operation) return;
|
||||
const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt;
|
||||
setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : "");
|
||||
setGatepassReference(operation.documents?.GATE_PASS?.reference ?? "");
|
||||
setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? "");
|
||||
setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? "");
|
||||
}, [gatepassQuery.data]);
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
[schedule?.bookings],
|
||||
@@ -899,6 +951,83 @@ export default function TrainScheduleV2DetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
|
||||
>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<Title order={4} fw={700}>
|
||||
Djibouti Port gate pass
|
||||
</Title>
|
||||
<Badge
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.direction === "IMPORT"
|
||||
? "Secure before dispatch from Djibouti."
|
||||
: "Secure after dispatch before Djibouti Port entry / unloading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" grow>
|
||||
<TextInput
|
||||
label="Secured date"
|
||||
type="datetime-local"
|
||||
value={gatepassSecuredAt}
|
||||
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document reference"
|
||||
placeholder="Optional"
|
||||
value={gatepassReference}
|
||||
onChange={(event) => setGatepassReference(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document URL"
|
||||
placeholder="Optional upload/link"
|
||||
value={gatepassFileUrl}
|
||||
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={gatepassNotes}
|
||||
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Save as Secured
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -21,11 +22,17 @@ import {
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
@@ -43,8 +50,54 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const locationTypesForFreight = (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 isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
function isUnloadPending(item: ImportTrainItem) {
|
||||
return !item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, AssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
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 (
|
||||
@@ -73,12 +126,26 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.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 = isUnloadPending(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -95,6 +162,41 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -102,7 +204,8 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -112,11 +215,36 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
|
||||
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',
|
||||
@@ -127,7 +255,9 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
@@ -169,10 +299,12 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train, assign each booking to a warehouse yard and zone, then unload it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -254,7 +386,7 @@ export default function ArrivalQueuePage() {
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
@@ -265,7 +397,27 @@ export default function ArrivalQueuePage() {
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[train.scheduleId]: {
|
||||
...(current[train.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId
|
||||
? draft
|
||||
: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -29,6 +29,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseGatewayPaymentMethod,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
@@ -164,15 +165,23 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}),
|
||||
);
|
||||
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
|
||||
const payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
|
||||
const [payerAccount, setPayerAccount] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
|
||||
setPayerAccount('');
|
||||
}, [inv?.id, inv?.currency]);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
@@ -302,6 +311,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnlinePay = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
const currentUrl = window.location.href;
|
||||
const result = await payOnline.mutateAsync({
|
||||
id: inv.id,
|
||||
payload: {
|
||||
method: gatewayMethod,
|
||||
platform: 'web',
|
||||
payerAccount: payerAccount.trim() || undefined,
|
||||
returnUrl: currentUrl,
|
||||
failureUrl: currentUrl,
|
||||
},
|
||||
});
|
||||
const url = result.clientAction?.url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: 'Payment initiated',
|
||||
description: 'No redirect URL was returned by the payment provider.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
@@ -359,7 +396,31 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
|
||||
{canPay && (
|
||||
<>
|
||||
<Divider label="Record payment" labelPosition="left" />
|
||||
<Divider label="Online payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<Select
|
||||
label="Provider"
|
||||
value={gatewayMethod}
|
||||
onChange={(v) => setGatewayMethod((v as WarehouseGatewayPaymentMethod) ?? 'TELEBIRR')}
|
||||
data={[
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Wallet phone / account"
|
||||
value={payerAccount}
|
||||
onChange={(e) => setPayerAccount(e.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
|
||||
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label="Record manual payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -38,6 +42,7 @@ const FREIGHT = [
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'BOTH', label: 'Import & Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
@@ -53,6 +58,23 @@ const numberValue = (value: string | number, fallback = 0) => {
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
const dash = '-';
|
||||
type CodeOptionSource = {
|
||||
id?: string;
|
||||
code?: string;
|
||||
cargoTypeName?: string;
|
||||
label?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const codeOptions = (rows: unknown[]) =>
|
||||
(rows as CodeOptionSource[])
|
||||
.filter((row) => row.code)
|
||||
.map((row) => ({
|
||||
value: row.code as string,
|
||||
label: `${row.cargoTypeName ?? row.label ?? row.name ?? row.code} (${row.code})`,
|
||||
}));
|
||||
|
||||
const isUnknownTiersError = (error: unknown) => extractErrorMessage(error).includes('property tiers should not exist');
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
@@ -319,6 +341,12 @@ function AllocationRules() {
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
|
||||
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -328,11 +356,56 @@ function FeeRules() {
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
const isContainerRule = form.freightType === 'CONTAINER';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
const last = f.tiers[f.tiers.length - 1];
|
||||
const fromDay = last?.toDay ? last.toDay + 1 : f.tiers.length ? last.fromDay + 1 : f.freeDays + 1;
|
||||
return {
|
||||
...f,
|
||||
tiers: [...f.tiers, { fromDay, toDay: fromDay, ratePerDay: f.ratePerDay || 0 }],
|
||||
};
|
||||
});
|
||||
|
||||
const updateTier = (
|
||||
index: number,
|
||||
patch: Partial<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tier, i) => (i === index ? { ...tier, ...patch } : tier)),
|
||||
}));
|
||||
|
||||
const removeTier = (index: number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.filter((_, i) => i !== index),
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
@@ -340,18 +413,68 @@ function FeeRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const tiers = form.tiers.map((tier) => ({
|
||||
fromDay: tier.fromDay,
|
||||
toDay: tier.toDay || null,
|
||||
ratePerDay: tier.ratePerDay,
|
||||
}));
|
||||
for (const [index, tier] of tiers.entries()) {
|
||||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: from day must be at least 1` });
|
||||
return;
|
||||
}
|
||||
if (tier.toDay != null && tier.toDay < tier.fromDay) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: to day must be after from day` });
|
||||
return;
|
||||
}
|
||||
if (tier.ratePerDay < 0) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: amount must be zero or greater` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
freightType: payload.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: payload.cargoTypeCode,
|
||||
containerType: payload.containerType,
|
||||
freeDays: payload.freeDays,
|
||||
ratePerDay: payload.ratePerDay,
|
||||
currency: payload.currency,
|
||||
};
|
||||
await create.mutateAsync(legacyPayload as never);
|
||||
toast({
|
||||
title: 'Fee rule created without tiers',
|
||||
description: 'The connected API does not support progressive tiers yet. Deploy the warehouse fee tier migration/API to save tier rows.',
|
||||
});
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -370,7 +493,7 @@ function FeeRules() {
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -378,6 +501,9 @@ function FeeRules() {
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Location scope</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
@@ -395,6 +521,20 @@ function FeeRules() {
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.containerType ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
) : (
|
||||
dash
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
@@ -454,7 +594,14 @@ function FeeRules() {
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
freightType: selectValue(value),
|
||||
cargoTypeCode: value === 'BULK' ? f.cargoTypeCode : '',
|
||||
containerType: value === 'CONTAINER' ? f.containerType : '',
|
||||
}))
|
||||
}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
@@ -464,15 +611,31 @@ function FeeRules() {
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder={cargoTypesLoading ? 'Loading cargo types...' : 'Any cargo'}
|
||||
data={cargoTypeOptions}
|
||||
value={form.cargoTypeCode || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, cargoTypeCode: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={cargoTypesLoading}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{isContainerRule && (
|
||||
<Select
|
||||
label="Container type"
|
||||
placeholder={containerTypesLoading ? 'Loading container types...' : 'Any container'}
|
||||
data={containerTypeOptions}
|
||||
value={form.containerType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, containerType: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={containerTypesLoading}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
@@ -494,6 +657,51 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Progressive tariff tiers
|
||||
</Text>
|
||||
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={addTier}>
|
||||
Add tier
|
||||
</Button>
|
||||
</Group>
|
||||
{form.tiers.map((tier, index) => (
|
||||
<Group key={index} grow align="end">
|
||||
<NumberInput
|
||||
label="From day"
|
||||
min={1}
|
||||
value={tier.fromDay}
|
||||
onChange={(value) => updateTier(index, { fromDay: numberValue(value, 1) || 1 })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="To day"
|
||||
min={tier.fromDay}
|
||||
value={tier.toDay ?? ''}
|
||||
placeholder="Open"
|
||||
onChange={(value) =>
|
||||
updateTier(index, {
|
||||
toDay: value === '' ? null : numberValue(value, tier.fromDay),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount / day"
|
||||
min={0}
|
||||
value={tier.ratePerDay}
|
||||
onChange={(value) => updateTier(index, { ratePerDay: numberValue(value) })}
|
||||
/>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => removeTier(index)} title="Remove tier">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
{form.tiers.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No stepped tiers. The flat rate per day is used after the free days.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -84,6 +84,7 @@ import type {
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
@@ -102,6 +103,7 @@ import type {
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseInvoiceFilter,
|
||||
@@ -943,12 +945,19 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
autoUnloadArrivedBookings: endpoint<string, AutoUnloadArrivedResult>(
|
||||
autoUnloadArrivedBookings: endpoint<
|
||||
{
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
},
|
||||
AutoUnloadArrivedResult
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"auto-unload-arrived-bookings",
|
||||
(scheduleId) =>
|
||||
({ scheduleId, warehouseId, assignments }) =>
|
||||
warehouseService
|
||||
.autoUnloadArrivedBookings(scheduleId)
|
||||
.autoUnloadArrivedBookings({ scheduleId, warehouseId, assignments })
|
||||
.then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
@@ -1111,6 +1120,18 @@ export const api = {
|
||||
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
|
||||
),
|
||||
|
||||
payInvoiceOnline: endpoint<
|
||||
{ id: string; payload: InitiateWarehouseInvoicePaymentPayload },
|
||||
WarehouseInvoicePaymentResponse
|
||||
>(
|
||||
"warehouse-fee-invoices",
|
||||
"pay-online",
|
||||
({ id, payload }) =>
|
||||
warehouseService.payInvoiceOnline(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
|
||||
),
|
||||
|
||||
gateClearance: endpoint<string, WarehouseInventoryItem>(
|
||||
"warehouse-fee-invoices",
|
||||
"gate-clearance",
|
||||
|
||||
@@ -29,9 +29,12 @@ export interface LastMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
vehicleType?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
}
|
||||
|
||||
export interface LastMileRecord {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Vehicle {
|
||||
capacity: number;
|
||||
status: VehicleStatus;
|
||||
description?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
@@ -171,10 +173,14 @@ export const warehouseService = {
|
||||
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
||||
importTrainItems: (scheduleId: string) =>
|
||||
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
|
||||
autoUnloadArrivedBookings: (scheduleId: string) =>
|
||||
autoUnloadArrivedBookings: (payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
apiClient.post<AutoUnloadArrivedResult>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
|
||||
{ scheduleId },
|
||||
payload,
|
||||
),
|
||||
importUnloadedQueue: () =>
|
||||
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
|
||||
@@ -294,6 +300,8 @@ export const warehouseService = {
|
||||
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
|
||||
payInvoice: (id: string, payload: PayInvoicePayload) =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
|
||||
payInvoiceOnline: (id: string, payload: InitiateWarehouseInvoicePaymentPayload) =>
|
||||
apiClient.post<WarehouseInvoicePaymentResponse>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY_ONLINE(id), payload),
|
||||
gateClearance: (inventoryId: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
|
||||
};
|
||||
|
||||
@@ -400,6 +400,7 @@ export interface TrainScheduleDetail {
|
||||
}
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| "GATE_PASS"
|
||||
| "DELIVERY_ORDER"
|
||||
| "PORT_INVOICE"
|
||||
| "DJIBOUTI_T1"
|
||||
@@ -422,6 +423,7 @@ export interface ImportDjiboutiOperation {
|
||||
status: {
|
||||
documentsComplete: boolean;
|
||||
missingDocuments: ImportDjiboutiDocumentType[];
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
gatepassGranted: boolean;
|
||||
readyForLoading: boolean;
|
||||
loadedOnTrain: boolean;
|
||||
@@ -430,6 +432,8 @@ export interface ImportDjiboutiOperation {
|
||||
};
|
||||
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
gatepassGrantedAt: string | null;
|
||||
gatepassSecuredAt?: string | null;
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
readyForLoadingAt: string | null;
|
||||
loadedOnTrainAt: string | null;
|
||||
departedFromDjiboutiAt: string | null;
|
||||
@@ -448,6 +452,10 @@ export interface UploadImportDjiboutiDocumentPayload {
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiActionPayload {
|
||||
securedAt?: string;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
@@ -223,6 +223,11 @@ export interface InventoryBookingRef {
|
||||
tradeDirection?: string | null;
|
||||
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
@@ -398,6 +403,11 @@ export interface EligibleBooking {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -433,6 +443,7 @@ export interface TruckEntrancePayload {
|
||||
packagingType?: string;
|
||||
unitCount?: number;
|
||||
grossWeightKg?: number;
|
||||
weighingRequired?: boolean;
|
||||
netWeightKg?: number;
|
||||
volumeDimensions?: string;
|
||||
conditionAtReceipt?: string;
|
||||
@@ -442,7 +453,7 @@ export interface TruckEntrancePayload {
|
||||
driverPhone: string;
|
||||
driverLicenseNumber?: string;
|
||||
truckType?: string;
|
||||
entranceTareWeightKg: number;
|
||||
entranceTareWeightKg?: number;
|
||||
exitTareWeightKg?: number;
|
||||
driverSignatoryName?: string;
|
||||
warehouseManagerName?: string;
|
||||
@@ -573,6 +584,11 @@ export interface ImportUnloadedItem {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -589,6 +605,7 @@ export interface ImportTrainItem {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -738,11 +755,25 @@ export interface FeeRule {
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
|
||||
export interface FeeRuleTier {
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
ratePerDay: number;
|
||||
}
|
||||
|
||||
export interface FeePreviewTier extends FeeRuleTier {
|
||||
appliedFromDay: number;
|
||||
appliedToDay: number;
|
||||
days: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
@@ -760,6 +791,7 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
@@ -868,6 +900,27 @@ export interface PayInvoicePayload {
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
export type WarehouseGatewayPaymentMethod = 'TELEBIRR' | 'WAAFI';
|
||||
|
||||
export interface InitiateWarehouseInvoicePaymentPayload {
|
||||
method: WarehouseGatewayPaymentMethod;
|
||||
platform?: 'web' | 'mobile';
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseInvoicePaymentResponse {
|
||||
intentId: string;
|
||||
status?: string;
|
||||
merchantOrderId?: string;
|
||||
clientAction?: {
|
||||
type?: string;
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
|
||||
Reference in New Issue
Block a user