train gate pass, Telebirr and Wafi

This commit is contained in:
hagiye
2026-07-01 16:23:52 +03:00
parent 38db9a4177
commit 14a00af341
41 changed files with 2807 additions and 151 deletions

View File

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

View File

@@ -142,6 +142,7 @@ interface TruckEntranceFormState {
packagingType: string;
unitCount: number | '';
grossWeightKg: number | '';
weighingRequired: boolean | null;
netWeightKg: number | '';
volumeDimensions: string;
conditionAtReceipt: string;
@@ -163,11 +164,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 +197,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
packagingType: '',
unitCount: '',
grossWeightKg: '',
weighingRequired: null,
netWeightKg: '',
volumeDimensions: '',
conditionAtReceipt: '',
@@ -206,13 +214,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 +241,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 +264,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 +309,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 +327,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 +375,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 +435,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 +463,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 +478,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 +585,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}
@@ -766,6 +832,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)));
@@ -822,32 +890,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));
@@ -1053,8 +1154,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 +1210,7 @@ function EligibleTab({
onChange={setTruckForm}
lockedFields={lockedTruckFields}
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
@@ -1934,6 +2036,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;

View File

@@ -82,6 +82,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;
@@ -138,14 +141,18 @@ 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');
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTruckPlateNumber(inspection.truckPlateNumber || assignedTruckPlate);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverName(inspection.driverName || assignedDriverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setTruckType(inspection.truckType || assignedTruckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
@@ -157,6 +164,8 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
@@ -269,7 +278,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 +292,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 +302,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={isTruckIdentityLocked} />
<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 +322,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>

View File

@@ -441,6 +441,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`,

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';
//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -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 */}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
@@ -28,6 +28,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';
@@ -162,15 +163,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 blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
@@ -280,6 +289,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 {
@@ -337,7 +374,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"

View File

@@ -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

View File

@@ -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,
@@ -1111,6 +1113,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",

View File

@@ -18,6 +18,8 @@ import type {
WarehouseFeeInvoice,
WarehouseInvoiceFilter,
PayInvoicePayload,
InitiateWarehouseInvoicePaymentPayload,
WarehouseInvoicePaymentResponse,
BookingScheduleView,
InventoryFilter,
InventoryInquiryFilter,
@@ -294,6 +296,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), {}),
};

View File

@@ -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;
}

View File

@@ -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;
@@ -738,11 +754,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 +790,7 @@ export interface FeePreview {
containerCount: number;
billableUnits: number;
amount: number;
tiers?: FeePreviewTier[];
}
export interface AllocationPreviewResult {
@@ -868,6 +899,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 {