resolve conflict

This commit is contained in:
yaschalew
2026-06-27 08:14:05 +03:00
69 changed files with 3289 additions and 299 deletions

View File

@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -374,6 +375,7 @@ const App = () => {
return (
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>

View File

@@ -1,6 +1,6 @@
import axios from "axios";
import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,

View File

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

View File

@@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
const pdfWindow = window.open('', '_blank');
try {
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
try {
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
} catch (documentError) {
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
});
}
onClose();
} catch (error) {
pdfWindow?.close();

View File

@@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ id }: { id: string }) {
/>
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
<DetailField label="Signed by EDR" value={document.generatedBy} />
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
<DetailField label="Customs Ref" value={document.customsReference} />
<DetailField label="Manifest Ref" value={document.manifestReference} />
</SimpleGrid>

View File

@@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
}
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
.join(' / ');
return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? (
@@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id}
{bookingReference}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {item.id}
{inventorySummary || 'Inventory information'}
</Text>
</Stack>
<InventoryStatusBadge status={item.status} />
@@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />

View File

@@ -18,16 +18,20 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { firstMileService } from '@/services/first-mile.service';
import type {
EligibleBooking,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
TruckEntrancePayload,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { InspectionReportModal } from './InspectionReportModal';
@@ -49,6 +53,305 @@ interface Location {
zoneId: string;
}
interface TruckEntranceFormState {
ownerName: string;
consigneeDetails: string;
edrDigitalBookingId: string;
tin: string;
truckPlateNumber: string;
trailerPlateNumber: string;
assignedEquipmentNumber: string;
customsSealNumber: string;
declarationNumber: string;
incoterms: string;
hsCodes: string;
itemCode: string;
itemDescription: string;
packagingType: string;
unitCount: number | '';
grossWeightKg: number | '';
netWeightKg: number | '';
volumeDimensions: string;
conditionAtReceipt: string;
damagedRejectedQuantity: number | '';
warehouseCodeLocation: string;
driverName: string;
driverPhone: string;
driverLicenseNumber: string;
truckType: string;
entranceTareWeightKg: number | '';
exitTareWeightKg: number | '';
driverSignatoryName: string;
warehouseManagerName: string;
}
const emptyTruckEntrance = (): TruckEntranceFormState => ({
ownerName: '',
consigneeDetails: '',
edrDigitalBookingId: '',
tin: '',
truckPlateNumber: '',
trailerPlateNumber: '',
assignedEquipmentNumber: '',
customsSealNumber: '',
declarationNumber: '',
incoterms: '',
hsCodes: '',
itemCode: '',
itemDescription: '',
packagingType: '',
unitCount: '',
grossWeightKg: '',
netWeightKg: '',
volumeDimensions: '',
conditionAtReceipt: '',
damagedRejectedQuantity: '',
warehouseCodeLocation: '',
driverName: '',
driverPhone: '',
driverLicenseNumber: '',
truckType: '',
entranceTareWeightKg: '',
exitTareWeightKg: '',
driverSignatoryName: '',
warehouseManagerName: '',
});
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,
truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
driverName: form.driverName.trim(),
driverPhone: form.driverPhone.trim(),
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
truckType: form.truckType.trim() || undefined,
entranceTareWeightKg: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
function TruckEntranceFields({
value,
onChange,
}: {
value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void;
}) {
return (
<Stack gap="sm">
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
<Group grow>
<TextInput
label="Owner's name"
value={value.ownerName}
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
/>
<TextInput
label="Consignee details"
value={value.consigneeDetails}
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="EDR digital booking ID"
value={value.edrDigitalBookingId}
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
/>
<TextInput
label="TIN"
value={value.tin}
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
/>
</Group>
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
<Group grow>
<TextInput
label="Truck plate number"
required
value={value.truckPlateNumber}
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
/>
<TextInput
label="Trailer plate number"
value={value.trailerPlateNumber}
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Assigned wagon / container number"
value={value.assignedEquipmentNumber}
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
/>
<TextInput
label="Customs seal number"
value={value.customsSealNumber}
onChange={(e) => onChange({ ...value, customsSealNumber: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Driver name"
required
value={value.driverName}
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
/>
<TextInput
label="Driver phone"
required
value={value.driverPhone}
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Driver license number"
value={value.driverLicenseNumber}
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
/>
<TextInput
label="Truck type"
value={value.truckType}
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
/>
</Group>
<Group grow>
<NumberInput
label="Entrance tare weight (kg)"
required
min={0}
value={value.entranceTareWeightKg}
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
<TextInput
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<TextInput
label="Incoterms"
value={value.incoterms}
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
/>
</Group>
<TextInput
label="HS codes"
value={value.hsCodes}
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
<Group grow>
<TextInput
label="Item code"
value={value.itemCode}
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
/>
<TextInput
label="Item description"
value={value.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Packaging type"
value={value.packagingType}
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
/>
<NumberInput
label="Unit count"
min={0}
value={value.unitCount}
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
<Group grow>
<NumberInput
label="Gross weight (kg)"
min={0}
value={value.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>
<TextInput
label="Volume / dimensions"
value={value.volumeDimensions}
onChange={(e) => onChange({ ...value, volumeDimensions: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Quality and inspection</Text>
<Group grow>
<TextInput
label="Condition at receipt"
value={value.conditionAtReceipt}
onChange={(e) => onChange({ ...value, conditionAtReceipt: e.currentTarget.value })}
/>
<NumberInput
label="Damaged / rejected quantity"
min={0}
value={value.damagedRejectedQuantity}
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput
label="Warehouse code and location"
value={value.warehouseCodeLocation}
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
/>
<Group grow>
<TextInput
label="Driver signatory"
value={value.driverSignatoryName}
onChange={(e) => onChange({ ...value, driverSignatoryName: e.currentTarget.value })}
/>
<TextInput
label="EDR warehouse manager"
value={value.warehouseManagerName}
onChange={(e) => onChange({ ...value, warehouseManagerName: e.currentTarget.value })}
/>
</Group>
</Stack>
);
}
/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */
function LocationSelects({
value,
@@ -140,20 +443,105 @@ function EligibleTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' });
},
onError: (error) => {
toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) });
},
});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusTab, setStatusTab] = useState('ALL');
const [truckOpen, setTruckOpen] = useState(false);
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const allSelected = rows.length > 0 && selected.size === rows.length;
const canReceiveBooking = (row: EligibleBooking) =>
!(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT');
const statusOptions = useMemo(() => {
const base = [{ value: 'ALL', label: 'All bookings' }];
if (direction === 'EXPORT') {
return [
...base,
{ value: 'DIRECT', label: 'Direct truck' },
{ value: 'FIRST_MILE', label: 'First mile' },
{ value: 'FIRST_MILE_READY', label: 'First mile arrived' },
{ value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' },
];
}
return [
...base,
{ value: 'READY_TO_RECEIVE', label: 'Ready to receive' },
{ value: 'PAID', label: 'Paid' },
];
}, [direction]);
const statusFilteredRows = useMemo(
() =>
rows.filter((row) => {
switch (statusTab) {
case 'DIRECT':
return !row.hasFirstMile;
case 'FIRST_MILE':
return row.hasFirstMile;
case 'FIRST_MILE_READY':
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
case 'AWAITING_FIRST_MILE':
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
case 'READY_TO_RECEIVE':
return canReceiveBooking(row);
case 'PAID':
return row.paymentStatus === 'PAID';
default:
return true;
}
}),
[rows, statusTab],
);
const statusCounts = useMemo(
() =>
Object.fromEntries(
statusOptions.map((option) => [
option.value,
rows.filter((row) => {
switch (option.value) {
case 'DIRECT':
return !row.hasFirstMile;
case 'FIRST_MILE':
return row.hasFirstMile;
case 'FIRST_MILE_READY':
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
case 'AWAITING_FIRST_MILE':
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
case 'READY_TO_RECEIVE':
return canReceiveBooking(row);
case 'PAID':
return row.paymentStatus === 'PAID';
default:
return true;
}
}).length,
]),
),
[rows, statusOptions],
);
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -161,7 +549,7 @@ function EligibleTab({
return next;
});
const receive = async (bookingIds: string[]) => {
const openTruckReceive = (bookingIds: string[]) => {
if (!locationReady) {
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
return;
@@ -170,13 +558,41 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Select at least one booking' });
return;
}
const allowedIds = new Set(selectableRows.map((row) => row.id));
const filteredIds = bookingIds.filter((id) => allowedIds.has(id));
if (filteredIds.length === 0) {
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
return;
}
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
setPendingReceiveIds(filteredIds);
setTruckForm({
...emptyTruckEntrance(),
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
});
setTruckOpen(true);
};
const receive = async () => {
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
try {
const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds });
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds: pendingReceiveIds,
truckEntrance: toTruckEntrancePayload(truckForm),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
@@ -198,9 +614,19 @@ function EligibleTab({
return (
<Stack gap="sm" mt="sm">
<Tabs value={statusTab} onChange={(v) => setStatusTab(v ?? 'ALL')}>
<Tabs.List>
{statusOptions.map((option) => (
<Tabs.Tab key={option.value} value={option.value}>
{option.label} ({statusCounts[option.value] ?? 0})
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {rows.length} eligible
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text>
<Group gap="xs">
{direction === 'EXPORT' && (
@@ -218,9 +644,9 @@ function EligibleTab({
<Button
size="compact-sm"
variant="default"
disabled={!locationReady || rows.length === 0}
disabled={!locationReady || selectableRows.length === 0}
loading={bulkReceive.isPending}
onClick={() => receive(rows.map((r) => r.id))}
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
>
Receive All Eligible
</Button>
@@ -228,7 +654,7 @@ function EligibleTab({
size="compact-sm"
disabled={!locationReady || selected.size === 0}
loading={bulkReceive.isPending}
onClick={() => receive([...selected])}
onClick={() => openTruckReceive([...selected])}
>
Receive Selected
</Button>
@@ -245,7 +671,7 @@ function EligibleTab({
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
) : statusFilteredRows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No eligible PAID {direction.toLowerCase()} bookings to receive.
</Text>
@@ -275,16 +701,20 @@ function EligibleTab({
<Table.Th>Payment</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Inspection</Table.Th>
{direction === 'EXPORT' && <Table.Th>First Mile</Table.Th>}
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
{statusFilteredRows.map((r) => {
const canReceive = canReceiveBooking(r);
return (
<Table.Tr key={r.id}>
<Table.Td>
<Checkbox
aria-label={`Select ${r.reference}`}
checked={selected.has(r.id)}
disabled={!canReceive}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
@@ -319,23 +749,73 @@ function EligibleTab({
</Badge>
</Table.Td>
<Table.Td></Table.Td>
{direction === 'EXPORT' && (
<Table.Td>
{r.hasFirstMile ? (
<Stack gap={2}>
<Badge
color={r.firstMileStatus === 'RECEIVED_TO_PORT' ? 'green' : r.firstMileRequestId ? 'blue' : 'orange'}
variant="light"
size="sm"
>
{r.firstMileStatus ?? 'Request needed'}
</Badge>
<Text size="xs" c="dimmed">
{[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'}
</Text>
</Stack>
) : (
<Badge color="gray" variant="light" size="sm">Direct arrival</Badge>
)}
</Table.Td>
)}
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
disabled={!locationReady}
loading={bulkReceive.isPending}
onClick={() => receive([r.id])}
>
Receive
</Button>
{direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={requestFirstMile.isPending}
onClick={() => requestFirstMile.mutate(r.reference)}
>
Request First Mile
</Button>
) : (
<Button
size="compact-xs"
variant="light"
disabled={!locationReady || !canReceive}
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive' : 'Await First Mile'}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
</Text>
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Receive and Generate GRN
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
@@ -687,6 +1167,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Last Mile</Table.Th>
<Table.Th>Pickup Option</Table.Th>
@@ -709,6 +1190,11 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
{it.inspectionStatus ?? 'Not inspected'}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color="gray">{it.currentStatus ?? '—'}</Badge>
</Table.Td>
@@ -726,6 +1212,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
}
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
const getPendingUnloadBookings = (train: ImportTrain) =>
train.pendingUnloadBookings ?? train.totalBookings;
const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
function ImportArriveQueueTab({
enabled,
onChanged,
@@ -744,9 +1236,19 @@ function ImportArriveQueueTab({
const [busyId, setBusyId] = useState<string | null>(null);
const autoUnload = async (train: ImportTrain) => {
if (isFullyUnloaded(train)) {
toast({
title: 'Already unloaded',
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
});
return;
}
setBusyId(train.scheduleId);
try {
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
const firstReason = r.results.find((item) => item.reason)?.reason;
const extra = [
r.skippedCount ? `${r.skippedCount} skipped` : '',
r.failedCount ? `${r.failedCount} failed` : '',
@@ -754,8 +1256,8 @@ function ImportArriveQueueTab({
.filter(Boolean)
.join(', ');
toast({
title: `${r.unloadedCount} unloaded`,
description: extra || undefined,
title: alreadyUnloaded ? 'Already unloaded' : `${r.unloadedCount} unloaded`,
description: alreadyUnloaded ? firstReason ?? 'This train is already in warehouse inventory.' : extra || undefined,
});
onChanged?.();
} catch (error) {
@@ -800,6 +1302,8 @@ function ImportArriveQueueTab({
<Table.Tbody>
{trains.map((t: ImportTrain) => {
const isOpen = openId === t.scheduleId;
const fullyUnloaded = isFullyUnloaded(t);
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
return (
<Fragment key={t.scheduleId}>
<Table.Tr>
@@ -817,7 +1321,14 @@ function ImportArriveQueueTab({
<Table.Td ta="center">{t.totalContainers}</Table.Td>
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
<Table.Td>
<Badge color="indigo" variant="light" size="sm">{t.status}</Badge>
<Stack gap={2}>
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
{fullyUnloaded ? 'UNLOADED' : t.status}
</Badge>
<Text size="xs" c="dimmed">
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
</Text>
</Stack>
</Table.Td>
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
@@ -831,12 +1342,13 @@ function ImportArriveQueueTab({
</Button>
<Button
size="compact-xs"
color="indigo"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0}
onClick={() => autoUnload(t)}
>
Auto Unload Arrived Bookings
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
</Group>
</Table.Td>
@@ -1171,11 +1683,13 @@ function SingleBookingReceiveModal({
volume: '',
notes: '',
});
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
useEffect(() => {
if (opened) {
setSelectedBooking(bookingId ?? '');
setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' });
setTruckForm(emptyTruckEntrance());
}
}, [opened, bookingId]);
@@ -1194,6 +1708,10 @@ function SingleBookingReceiveModal({
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
return;
}
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
const payload: ReceiveInventoryPayload = {
bookingId: selectedBooking.trim(),
warehouseId: form.warehouseId,
@@ -1203,6 +1721,7 @@ function SingleBookingReceiveModal({
weight: Number(form.weight),
volume: form.volume === '' ? undefined : Number(form.volume),
notes: form.notes.trim() || undefined,
truckEntrance: toTruckEntrancePayload(truckForm),
};
try {
await receiveMutation.mutateAsync(payload);
@@ -1249,6 +1768,8 @@ function SingleBookingReceiveModal({
/>
</Group>
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
<Textarea
label="Notes"
placeholder="Optional notes"
@@ -1266,7 +1787,7 @@ function SingleBookingReceiveModal({
Cancel
</Button>
<Button onClick={handleSubmit} loading={receiveMutation.isPending}>
Receive inventory
Receive inventory and generate GRN
</Button>
</Group>
</Stack>

View File

@@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</ActionIcon>
</Group>
),
},
},
];
return (

View File

@@ -0,0 +1,273 @@
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
import type { BookingDetail } from '@/types/booking';
type PdfLine = {
text: string;
size?: number;
bold?: boolean;
x?: number;
yGap?: number;
color?: 'black' | 'green';
align?: 'left' | 'center' | 'right';
};
export interface WarehouseExitPaperContext {
invoice: WarehouseFeeInvoice;
releasedItem?: WarehouseInventoryItem;
inventory?: WarehouseInventoryItem;
booking?: BookingDetail | null;
releasedAt?: Date;
}
const escapePdfText = (value: string) =>
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const money = (amount: unknown, currency = 'USD') =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const fmtDate = (value: unknown) => {
if (!value) return '-';
const date = new Date(value as string | Date);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
const GREEN = '0 0.55 0.32';
const circlePath = (cx: number, cy: number, r: number) => {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
'h',
].join('\n');
};
const stampText = (text: string, x: number, y: number, size: number, bold = false) =>
`BT\n${GREEN} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildCircularSeal = (cx: number, cy: number, label: 'PAID' | 'CLEARED') =>
[
'q',
`${GREEN} RG`,
`${GREEN} rg`,
'2.2 w',
circlePath(cx, cy, 52),
'S',
'0.9 w',
circlePath(cx, cy, 42),
'S',
stampText('EDR FREIGHT', cx - 33, cy + 24, 9, true),
stampText(label, cx - (label === 'CLEARED' ? 36 : 21), cy - 4, label === 'CLEARED' ? 17 : 20, true),
stampText(label === 'CLEARED' ? 'GATE RELEASE' : 'WAREHOUSE', cx - (label === 'CLEARED' ? 34 : 32), cy - 25, 8),
'Q',
].join('\n');
const estimateTextWidth = (text: string, size: number) => text.length * size * 0.52;
const textX = (text: string, size: number, align: PdfLine['align'] = 'left', x?: number) => {
if (typeof x === 'number') return x;
if (align === 'center') return Math.max(36, (595 - estimateTextWidth(text, size)) / 2);
if (align === 'right') return Math.max(36, 535 - estimateTextWidth(text, size));
return 60;
};
const lineOp = (x1: number, y1: number, x2: number, y2: number, color = '0.65 0.7 0.76') =>
`q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
const textOp = (
text: string,
x: number,
y: number,
size = 10,
bold = false,
color = '0 0 0',
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
lineOp(60, 242, 535, 242),
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
buildCircularSeal(452, 155, label),
];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
buildCircularSeal(170, 128, 'CLEARED'),
textOp('Officer in charge name / signature / date:', 292, 154, 10),
lineOp(292, 132, 535, 132, '0 0 0'),
textOp('Customer or driver name / signature / date:', 292, 94, 10),
lineOp(292, 72, 535, 72, '0 0 0'),
];
function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
let y = 800;
const streamLines = lines.map((line) => {
y -= line.yGap ?? 16;
const size = line.size ?? 10;
const font = line.bold ? '/F2' : '/F1';
const color = line.color === 'green' ? `${GREEN} rg` : '0 0 0 rg';
return `BT\n${color}\n${font} ${size} Tf\n${textX(line.text, size, line.align, line.x)} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`;
});
const stream = [...rawOps, ...streamLines].join('\n');
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(pdf.length);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xref = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
offsets.slice(1).forEach((offset) => {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
});
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new Blob([pdf], { type: 'application/pdf' });
}
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [
{ text: item.description, bold: true, align: 'center' as const },
{
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
yGap: 13,
align: 'center' as const,
},
]),
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
];
const authorizationOps = [
...buildAuthorizationBand('PAID'),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
const invoiceOps = [
lineOp(60, 242, 535, 242),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
}
const firstText = (...values: Array<unknown>) => {
for (const value of values) {
if (value !== null && value !== undefined && String(value).trim()) return String(value);
}
return '-';
};
const tons = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num) || num <= 0) return null;
return `${num.toLocaleString(undefined, { maximumFractionDigits: 3 })} ton`;
};
const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseInventoryItem) => {
const explicitNumber = (inventory as unknown as { containerNumber?: string | null })?.containerNumber;
if (explicitNumber) return explicitNumber;
const containers = booking?.bookingContainers ?? [];
if (!containers.length) return '-';
return containers
.map((item) => {
const type = item.containerType?.code ?? item.containerType?.label ?? item.containerTypeId;
return `${item.quantity} x ${type}`;
})
.join(', ');
};
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
const context: WarehouseExitPaperContext =
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
const { invoice, booking } = context;
const releasedItem = context.releasedItem;
const inventory = context.inventory ?? releasedItem;
const releasedAt = context.releasedAt ?? new Date();
const releaseReference = firstText(
inventory?.releaseOrderReference,
releasedItem?.releaseOrderReference,
invoice.inventoryReference,
booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null,
);
const customerName = firstText(
booking?.company?.name,
booking?.company?.companyName,
booking?.company?.label,
booking?.company?.contactPersonName,
invoice.customerName,
);
const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight));
const inventoryInfo = firstText(
invoice.inventoryInfo,
invoice.containerNumber,
invoice.cargoDescription,
inventory?.status,
releasedItem?.status,
);
const bookingReference = firstText(
booking?.reference,
(inventory as unknown as { bookingReference?: string })?.bookingReference,
invoice.bookingReference,
releasedItem?.booking?.reference,
);
return buildSimplePdf([
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: 'Warehouse Release / Exit Paper', size: 23, bold: true, yGap: 28, align: 'center' },
{ text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
{ text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' },
{ text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
{ text: `Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' },
{ text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' },
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' },
{ text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' },
{ text: `Weight: ${weightTons}`, align: 'center' },
{ text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' },
{ text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' },
{ text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' },
{ text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' },
], [
...buildWarehouseOfficerSealBand(),
]);
}

View File

@@ -358,6 +358,8 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices',
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,

View File

@@ -1,4 +1,6 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com';
export const API_BASE_URL =
import.meta.env.VITE_BASE_API_URL ||
import.meta.env.VITE_API_URL ||
'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,4 +1,5 @@
import type { FleetResourceConfig } from "./resources";
import { API_BASE_URL } from "@/constants/apiConfig";
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
@@ -92,6 +93,4 @@ export const vehiclesConfig: FleetResourceConfig = {
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com';
export { API_BASE_URL };

View File

@@ -89,7 +89,7 @@ const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
const parts = [r.booking?.cargoType?.label ?? r.booking?.cargoFreeText].filter(Boolean);
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
return parts.join(" · ") || "—";
};
@@ -347,6 +347,10 @@ const FirstMilePage = () => {
const paidBookings = paidBookingsData?.items ?? [];
const records = listData?.data ?? [];
const existingFirstMileBookingIds = useMemo(
() => new Set(records.map((record) => record.bookingId)),
[records],
);
const vehicleOptions = useMemo(
() =>
@@ -398,16 +402,27 @@ const FirstMilePage = () => {
[rowSelection],
);
const firstMileEligiblePaidBookings = useMemo(
() =>
paidBookings.filter(
(booking) =>
booking.tradeDirection === "EXPORT" &&
Boolean(booking.firstMilePickupAddress?.trim()) &&
!existingFirstMileBookingIds.has(booking.id),
),
[existingFirstMileBookingIds, paidBookings],
);
const filteredPaidBookings = useMemo(() => {
const term = bookingSearch.trim().toLowerCase();
if (!term) return paidBookings;
return paidBookings.filter((b) =>
if (!term) return firstMileEligiblePaidBookings;
return firstMileEligiblePaidBookings.filter((b) =>
[b.reference, b.company?.name, b.company?.companyName]
.join(" ")
.toLowerCase()
.includes(term),
);
}, [paidBookings, bookingSearch]);
}, [firstMileEligiblePaidBookings, bookingSearch]);
const openAccept = () => {
setAcceptOpen(true);
@@ -879,7 +894,7 @@ const FirstMilePage = () => {
{bookingsLoading ? (
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings</Text>
) : filteredPaidBookings.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
) : (
filteredPaidBookings.map((b) => (
<UnstyledButton

View File

@@ -37,6 +37,12 @@ const getErrorMessage = (error: unknown) => {
return error instanceof Error ? error.message : undefined;
};
const getPendingUnloadBookings = (train: ImportTrain) =>
train.pendingUnloadBookings ?? train.totalBookings;
const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
@@ -67,6 +73,7 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Pickup</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -88,6 +95,11 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
{item.currentStatus ?? 'PENDING'}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
{item.inspectionStatus ?? 'Not inspected'}
</Badge>
</Table.Td>
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
</Table.Tr>
))}
@@ -105,10 +117,20 @@ export default function ArrivalQueuePage() {
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const unloadTrain = async (train: ImportTrain) => {
if (isFullyUnloaded(train)) {
toast({
title: 'Already unloaded',
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
});
return;
}
setBusyScheduleId(train.scheduleId);
try {
const res = (await autoUnload.mutateAsync(train.scheduleId)) 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;
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
result.failedCount ? `${result.failedCount} failed` : '',
@@ -117,8 +139,10 @@ export default function ArrivalQueuePage() {
.join(', ');
toast({
title: `${result.unloadedCount} booking(s) unloaded`,
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`,
description: alreadyUnloaded
? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.`
: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
});
} catch (error) {
toast({
@@ -181,6 +205,8 @@ export default function ArrivalQueuePage() {
<Table.Tbody>
{trains.map((train: ImportTrain) => {
const isOpen = openScheduleId === train.scheduleId;
const fullyUnloaded = isFullyUnloaded(train);
const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train);
return (
<Fragment key={train.scheduleId}>
<Table.Tr>
@@ -204,9 +230,14 @@ export default function ArrivalQueuePage() {
<Table.Td ta="center">{train.totalContainers}</Table.Td>
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
<Table.Td>
<Badge variant="light" color="teal" size="sm">
{train.status}
</Badge>
<Stack gap={2}>
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
{fullyUnloaded ? 'UNLOADED' : train.status}
</Badge>
<Text size="xs" c="dimmed">
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
@@ -220,12 +251,13 @@ export default function ArrivalQueuePage() {
</Button>
<Button
size="compact-xs"
color="orange"
color={fullyUnloaded ? 'gray' : 'orange'}
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
loading={busyScheduleId === train.scheduleId}
disabled={fullyUnloaded || train.totalBookings === 0}
onClick={() => unloadTrain(train)}
>
Auto Unload
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
</Button>
</Group>
</Table.Td>

View File

@@ -193,14 +193,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
result.skippedCount ? `${result.skippedCount} skipped` : '',
result.failedCount ? `${result.failedCount} failed` : '',
result.interchangeDocument
? `Interchange document ${result.interchangeDocument.documentNo} generated`
? `Signed interchange document ${result.interchangeDocument.documentNo} generated`
: '',
]
.filter(Boolean)
.join(', ');
toast({
title: `${result.unloadedCount} export item(s) unloaded`,
title: `${result.unloadedCount} export item(s) auto unloaded`,
description: details || `${train.trainNumber ?? 'Train'} unloaded at Djibouti Port.`,
});
} catch (error) {
@@ -224,7 +224,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
remarks: 'Generated after export unloading at Djibouti Port',
generatedBy: 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
toast({
title: 'Interchange document generated',
@@ -249,21 +250,21 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Stack gap="lg" mt="sm">
<PageHeader
title="Djibouti Arrival / Unloading Queue"
subtitle="Arrived export trains at Djibouti-side destinations ready for unloading."
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
/>
<WarehouseHero
variant="train"
secondaryVariant="container"
title="Export Unloading at Djibouti Port"
subtitle="Review arrived export trains and unload eligible assigned export items."
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{trains.length} arrived export train(s)</Text>
<Text size="sm" c="dimmed">
Open a train to review assigned export items, then auto unload it.
Open a train, auto unload it, then view the signed interchange document.
</Text>
</Group>
@@ -368,7 +369,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
loading={busyScheduleId === train.scheduleId && generateInterchange.isPending}
onClick={() => generateInterchangeDocument(train)}
>
Generate Interchange Document
Generate Signed Document
</Button>
)}
</Group>

View File

@@ -93,6 +93,8 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
/>
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
<DetailField label="Signed by EDR" value={document.generatedBy} />
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
<DetailField label="Customs Ref" value={document.customsReference} />
<DetailField label="Manifest Ref" value={document.manifestReference} />
</SimpleGrid>
@@ -221,6 +223,7 @@ export default function InterchangeDocumentsPage() {
<Table.Th>Handover From</Table.Th>
<Table.Th>Handover To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Signed By</Table.Th>
<Table.Th>Generated At</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
@@ -251,6 +254,14 @@ export default function InterchangeDocumentsPage() {
{document.status}
</Badge>
</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{document.generatedBy ?? '-'}</Text>
<Text size="xs" c="dimmed">
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
</Text>
</Stack>
</Table.Td>
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">

View File

@@ -15,19 +15,24 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { bookingsService } from '@/services/bookings.service';
import { warehouseService } from '@/services/warehouse.service';
import { useToast } from '@/hooks/use-toast';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
@@ -158,18 +163,108 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
);
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
const [payAmount, setPayAmount] = useState<number | ''>('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
};
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
};
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
const [booking, inventoryRows] = await Promise.all([
invoice.bookingId
? bookingsService.getById(invoice.bookingId).catch(() => null)
: Promise.resolve(null),
invoice.bookingId
? warehouseService.listInventory({ bookingId: invoice.bookingId }).then((response) => response.data).catch(() => [])
: Promise.resolve([]),
]);
const inventory = inventoryRows.find((item) => item.id === invoice.inventoryId) ?? inventoryRows[0] ?? undefined;
return { booking, inventory };
};
const handleGateClearance = async (invoice: WarehouseFeeInvoice) => {
if (!invoice.inventoryId) {
toast({
variant: 'destructive',
title: 'Gate clearance failed',
description: 'This invoice is not linked to an inventory item.',
});
return;
}
const pdfWindow = window.open('', '_blank');
try {
const releasedAt = new Date();
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
try {
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
} catch (documentError) {
const context = await getExitPaperContext(invoice);
const fallbackBlob = buildWarehouseExitPaperPdf({
invoice,
releasedItem,
inventory: context.inventory,
booking: context.booking,
releasedAt,
});
const opened = openPdfBlob(
fallbackBlob,
`release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`,
pdfWindow,
);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The API exit paper failed, so a sealed fallback PDF opened instead.'
: `The API exit paper failed (${extractErrorMessage(documentError)}), so a sealed fallback PDF was downloaded.`,
});
onClose();
return;
}
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The exit paper opened in a browser tab.'
: 'The browser blocked the preview tab, so the exit paper was downloaded.',
});
onClose();
} catch (e) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Gate clearance failed',
description: extractErrorMessage(e),
});
}
};
const handlePay = async () => {
if (!inv || !payAmount) return;
try {
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
toast({ title: 'Payment recorded' });
const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
setPayAmount('');
if (paidInvoice.status === 'PAID') {
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
await downloadReceiptPdf(paidInvoice);
await handleGateClearance(paidInvoice);
} else {
toast({ title: 'Payment recorded' });
}
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
}
};
@@ -180,7 +275,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
toast({ title: 'Invoice cancelled' });
onClose();
} catch (e) {
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) });
}
};
@@ -247,6 +342,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
)}
<Group justify="flex-end" mt="sm">
<Button
variant="light"
color="gray"
leftSection={<Download size={16} />}
onClick={() => downloadInvoicePdf(inv)}
>
Invoice PDF
</Button>
{Number(inv.paidAmount) > 0 && (
<Button
variant="light"
color="teal"
leftSection={<Receipt size={16} />}
onClick={() => downloadReceiptPdf(inv)}
>
Receipt PDF
</Button>
)}
{canGateClear && (
<Button
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={() => handleGateClearance(inv)}
>
Gate clearance & exit paper
</Button>
)}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice

View File

@@ -263,6 +263,14 @@ export const warehouseService = {
}),
getInvoice: (id: string) =>
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
downloadInvoiceDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.DOCUMENT(id), {
responseType: 'blob',
}),
downloadInvoiceReceipt: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.RECEIPT(id), {
responseType: 'blob',
}),
invoicesForInventory: (inventoryId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) =>

View File

@@ -364,6 +364,12 @@ export interface EligibleBooking {
weight: string | null;
paymentStatus: string;
status: string;
hasFirstMile: boolean;
firstMileRequestId: string | null;
firstMileStatus: string | null;
firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null;
}
export interface BulkReceivePayload {
@@ -372,12 +378,45 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
truckEntrance: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface TruckEntrancePayload {
ownerName?: string;
consigneeDetails?: string;
edrDigitalBookingId?: string;
tin?: string;
truckPlateNumber: string;
trailerPlateNumber?: string;
assignedEquipmentNumber?: string;
customsSealNumber?: string;
declarationNumber?: string;
incoterms?: string;
hsCodes?: string;
itemCode?: string;
itemDescription?: string;
packagingType?: string;
unitCount?: number;
grossWeightKg?: number;
netWeightKg?: number;
volumeDimensions?: string;
conditionAtReceipt?: string;
damagedRejectedQuantity?: number;
warehouseCodeLocation?: string;
driverName: string;
driverPhone: string;
driverLicenseNumber?: string;
truckType?: string;
entranceTareWeightKg: number;
exitTareWeightKg?: number;
driverSignatoryName?: string;
warehouseManagerName?: string;
}
export interface LoadPassedExportResult {
@@ -430,6 +469,9 @@ export interface ImportTrain {
totalBookings: number;
totalContainers: number;
totalCargoes: number;
unloadedBookings?: number;
pendingUnloadBookings?: number;
fullyUnloaded?: boolean;
status: string;
}
@@ -510,6 +552,7 @@ export interface ImportTrainItem {
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
@@ -733,8 +776,16 @@ export interface WarehouseFeeInvoice {
id: string;
invoiceNumber: string;
bookingId?: string | null;
bookingReference?: string | null;
customerId?: string | null;
customerName?: string | null;
inventoryId: string;
inventoryReference?: string | null;
inventoryInfo?: string | null;
inventoryStatus?: string | null;
containerNumber?: string | null;
cargoDescription?: string | null;
clearanceStatus?: string | null;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
@@ -823,6 +874,7 @@ export interface ReceiveInventoryPayload {
weight: number;
volume?: number;
notes?: string;
truckEntrance: TruckEntrancePayload;
}
export interface MoveInventoryPayload {