mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
fix conflict
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183",
|
||||
"dev": "vite --port 5183 --clearScreen false",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
|
||||
@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||
const bookingReference = item?.booking?.reference ?? '-';
|
||||
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
|
||||
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
|
||||
const inventorySummary = [
|
||||
item?.status?.replace(/_/g, ' '),
|
||||
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
|
||||
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
|
||||
handoverReference ? `Handover ${handoverReference}` : null,
|
||||
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -61,11 +70,13 @@ 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="GRN" value={item.grnNumber ?? '-'} />
|
||||
<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="Inventory status" value={item.status.replace(/_/g, ' ')} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||
<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)} />
|
||||
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
|
||||
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
|
||||
onReceived?: () => void;
|
||||
}
|
||||
|
||||
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Location {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
@@ -620,11 +659,13 @@ function EligibleTab({
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: allRows = [], isLoading } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({ enabled }),
|
||||
api.warehouses.eligibleBookings.queryOptions({
|
||||
input: { direction },
|
||||
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: () => {
|
||||
@@ -782,10 +823,24 @@ function EligibleTab({
|
||||
return;
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
const totalContainerQuantity = selectedRows.reduce(
|
||||
(sum, row) => sum + Number(row.containerQuantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const normalizedForm =
|
||||
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
||||
? {
|
||||
...form,
|
||||
unitCount: totalContainerQuantity,
|
||||
}
|
||||
: form;
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setTruckForm(normalizedForm);
|
||||
setLockedTruckFields({
|
||||
...lockedFields,
|
||||
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
|
||||
});
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
@@ -798,18 +853,6 @@ function EligibleTab({
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
};
|
||||
|
||||
const loadPassedExport = async () => {
|
||||
try {
|
||||
const r = await loadPassed.mutateAsync(undefined);
|
||||
toast({
|
||||
title: `${r.loadedCount} loaded`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
|
||||
});
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
@@ -828,18 +871,6 @@ function EligibleTab({
|
||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{direction === 'EXPORT' && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={loadPassed.isPending}
|
||||
onClick={loadPassedExport}
|
||||
>
|
||||
Load Passed Export Items
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
||||
@@ -849,7 +880,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||
>
|
||||
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
||||
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -858,7 +889,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
>
|
||||
Receive Selected
|
||||
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -1000,7 +1031,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
||||
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
@@ -1015,7 +1046,7 @@ function EligibleTab({
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Receive to Warehouse"
|
||||
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
try {
|
||||
const response = await warehouseService.downloadHandoverDocument(row.id);
|
||||
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const openReleaseDocument = async (row: ImportUnloadedItem) => {
|
||||
setBusyId(row.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadReleaseDocument(row.id);
|
||||
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Arrival Time</Table.Th>
|
||||
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck Arrival
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Dispatch
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={14} />}
|
||||
loading={busyId === r.id}
|
||||
onClick={() => openReleaseDocument(r)}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
Handover
|
||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
|
||||
const { data: eligibleRows = [] } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({
|
||||
input: { direction: 'EXPORT' },
|
||||
enabled,
|
||||
}),
|
||||
);
|
||||
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
||||
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
||||
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
};
|
||||
|
||||
const toLocalDateTimeInput = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
|
||||
const bookingReference = item?.booking?.reference;
|
||||
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
|
||||
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
const lineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
||||
if (!value) return '';
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : '';
|
||||
};
|
||||
|
||||
const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
(value ?? '')
|
||||
.split(/[,;\n]+/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||
|
||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
|
||||
};
|
||||
|
||||
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
|
||||
const savedNumbers = splitContainerNumbers(savedContainerNumber);
|
||||
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
|
||||
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
|
||||
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
|
||||
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
|
||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||
};
|
||||
|
||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
||||
return {
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
};
|
||||
};
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const [driverLicense, setDriverLicense] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [truckType, setTruckType] = useState('');
|
||||
const [containerNumber, setContainerNumber] = useState('');
|
||||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setReference(item?.releaseOrderReference ?? '');
|
||||
setTruckPlateNumber('');
|
||||
setTrailerPlateNumber('');
|
||||
setDriverName('');
|
||||
setDriverLicense('');
|
||||
setDriverPhone('');
|
||||
setTruckType('');
|
||||
setContainerNumber('');
|
||||
setGateInTime('');
|
||||
setTareWeight('');
|
||||
setGrossWeight('');
|
||||
setNetWeight(item?.weight != null ? Number(item.weight) : '');
|
||||
setGateOutTime('');
|
||||
const inspection = parseInspectionNote(item?.notes);
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber);
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
||||
setDriverName(inspection.driverName);
|
||||
setDriverLicense(inspection.driverLicense);
|
||||
setDriverPhone(inspection.driverPhone);
|
||||
setTruckType(inspection.truckType);
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isEntranceLocked = isExitStep;
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
|
||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (tareWeight === '' || grossWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
|
||||
if (!gateInTime || tareWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
||||
return;
|
||||
}
|
||||
if (weightMismatch) {
|
||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && weightMismatch) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Weight mismatch',
|
||||
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
|
||||
try {
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
driverLicense: driverLicense.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumber.trim() || undefined,
|
||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: Number(grossWeight),
|
||||
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
|
||||
gateOutTime: toIsoDateTime(gateOutTime),
|
||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
if (!isExitStep) {
|
||||
toast({
|
||||
title: 'Truck arrival saved',
|
||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Register the customer truck and driver at arrival, record tare weight, then record gross
|
||||
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
||||
equal gross weight minus tare weight.
|
||||
</Text>
|
||||
{isExitStep ? (
|
||||
<Text size="sm">
|
||||
Record the truck leaving time and gross weight. The system recorded net weight is locked,
|
||||
and the exit paper is generated only when it equals gross weight minus tare weight.
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm">
|
||||
Register the customer truck and driver at arrival, then save gate in time and tare weight.
|
||||
Reopen this form when the truck is leaving to complete the exit weighing.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
disabled={isEntranceLocked}
|
||||
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);
|
||||
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<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)} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
|
||||
<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} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Save Truck Arrival & View Exit Paper
|
||||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import {
|
||||
getNextInventoryAction,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
|
||||
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
|
||||
|
||||
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!item.grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(item.id);
|
||||
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!item.grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{item.grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
items,
|
||||
busyId,
|
||||
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||
const handoverReference = handoverDocumentReference(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton item={item} />
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
||||
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
|
||||
</Tooltip>
|
||||
)}
|
||||
{onHandoverDocument && canGenerateHandover && (
|
||||
<Tooltip label="Generate customer handover document" withArrow>
|
||||
<Tooltip
|
||||
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
|
||||
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);
|
||||
@@ -96,12 +93,6 @@ const textOp = (
|
||||
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),
|
||||
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
|
||||
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);
|
||||
|
||||
@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
|
||||
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||
* window so it still lands on sections that mount after an async fetch (there is
|
||||
* no router-level hash handling). Deep-link targets give a card an `id`.
|
||||
*/
|
||||
export function useScrollToHash(): void {
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const id = decodeURIComponent(hash.slice(1));
|
||||
let tries = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
@@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { PackageSearch } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
|
||||
import { PackageSearch, Truck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ExportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
const [receiveOpen, setReceiveOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
|
||||
title="Export Operations"
|
||||
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||
action={
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
fw={700}
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={() => setReceiveOpen(true)}
|
||||
>
|
||||
Receive for Loading
|
||||
</Button>
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={receiveOpen}
|
||||
onClose={() => setReceiveOpen(false)}
|
||||
title="Receive for loading"
|
||||
centered
|
||||
size="80rem"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setReceiveOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
||||
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
|
||||
|
||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: inv, isLoading } = useQuery(
|
||||
api.warehouses.invoice.queryOptions({
|
||||
input: { id: id ?? '' },
|
||||
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
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 pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
|
||||
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Download failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
|
||||
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Download failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
||||
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{inv.bookingId && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View booking
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
|
||||
@@ -656,11 +656,11 @@ export const api = {
|
||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||
),
|
||||
|
||||
eligibleBookings: endpoint<void, EligibleBooking[]>(
|
||||
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
||||
"warehouse-inventory",
|
||||
"eligible-bookings",
|
||||
() => warehouseService.eligibleBookings().then((r) => r.data),
|
||||
() => ["warehouse-inventory", "eligible-bookings"],
|
||||
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
||||
),
|
||||
|
||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
|
||||
@@ -137,6 +137,10 @@ export const warehouseService = {
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
downloadGrnDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
downloadHandoverDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
|
||||
quantity: number;
|
||||
weight: number;
|
||||
volume: number | null;
|
||||
grnNumber: string | null;
|
||||
status: InventoryStatus;
|
||||
inspectionStatus: string | null;
|
||||
arrivedAt: string | null;
|
||||
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
|
||||
readyForPickupAt: string | null;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference?: string | null;
|
||||
handoverDocumentDate?: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
warehouse?: Warehouse | null;
|
||||
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
grnNumber: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
inspectionStatus: string | null;
|
||||
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
grnNumber: string | null;
|
||||
trainSchedule: string | null;
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"dev": "vite --port 5173 --clearScreen false",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -147,6 +147,16 @@ export const URL_CONSTANTS = {
|
||||
BILLING: {
|
||||
MY_INVOICES: "/api/billing/my-invoices",
|
||||
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
FOR_BOOKING: (bookingId: string) =>
|
||||
`/api/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
@@ -12,4 +11,3 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
|
||||
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||
* window so it still lands on sections that mount after an async fetch (the app
|
||||
* has no router-level hash handling). Deep-link targets give a card an `id`.
|
||||
*/
|
||||
export function useScrollToHash(): void {
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const id = decodeURIComponent(hash.slice(1));
|
||||
let tries = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ContractSignButton,
|
||||
bookingIsSignable,
|
||||
} from "@/pages/bookings/contract/ContractSignButton";
|
||||
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
|
||||
|
||||
interface BookingRowProps {
|
||||
booking: any;
|
||||
@@ -35,6 +36,7 @@ export const BookingRow = memo(function BookingRow({
|
||||
// Contract ready for signature → "View & sign" jumps straight to the
|
||||
// full-page contract viewer where the signature flow lives.
|
||||
const canSign = bookingIsSignable(booking);
|
||||
const canApproveDelivery = booking.status === "COMPLETED";
|
||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||
@@ -99,6 +101,12 @@ export const BookingRow = memo(function BookingRow({
|
||||
<PayNowButton booking={booking} size="sm" />
|
||||
) : canSign ? (
|
||||
<ContractSignButton booking={booking} size="sm" />
|
||||
) : canApproveDelivery ? (
|
||||
<ApproveDeliveryButton
|
||||
bookingId={booking.id}
|
||||
size="sm"
|
||||
stopPropagation
|
||||
/>
|
||||
) : hasInlineAction ? (
|
||||
<BookingActionButton booking={booking} size="sm" />
|
||||
) : (
|
||||
|
||||
@@ -15,9 +15,16 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
||||
import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import {
|
||||
@@ -49,14 +56,29 @@ export default function InvoiceDetailPage() {
|
||||
api.invoices.get.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
const payMutation = useMutation(
|
||||
api.invoices.pay.mutationOptions({
|
||||
onSuccess: (res) => {
|
||||
const url = res.clientAction?.url;
|
||||
if (url) window.location.href = url;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
|
||||
// Extracted for payMutation callbacks — guaranteed defined when they run
|
||||
// (guarded by the early return below).
|
||||
const invSource = invoice?.source;
|
||||
const invSourceId = invoice?.sourceId;
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: async (method: PaymentMethod) => {
|
||||
const bookingId =
|
||||
invSource === "warehouse"
|
||||
? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId!
|
||||
: invSourceId!;
|
||||
return api.payments.initiate.call({ bookingId, method });
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId: invSourceId!, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -90,9 +112,55 @@ export default function InvoiceDetailPage() {
|
||||
const lines = invoice.lines ?? [];
|
||||
|
||||
const handlePay = () => {
|
||||
const returnUrl = `${window.location.origin}/payment/success`;
|
||||
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||
setPayModalOpen(true);
|
||||
};
|
||||
|
||||
const hasReceipt = Number(invoice.paidAmount) > 0;
|
||||
const canViewSource =
|
||||
invoice.source === "booking" || invoice.source === "warehouse";
|
||||
|
||||
const downloadInvoice = async () => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadDocument(id),
|
||||
`invoice-${invoice.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async () => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadReceipt(id),
|
||||
`receipt-${invoice.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
// The source link: a booking invoice goes straight to the booking; a warehouse
|
||||
// fee invoice resolves its booking (via the warehouse view) and deep-links to
|
||||
// that booking's warehouse-payments section.
|
||||
const viewSource = async () => {
|
||||
if (invoice.source === "booking") {
|
||||
navigate(`/bookings/${invoice.sourceId}`);
|
||||
return;
|
||||
}
|
||||
if (invoice.source === "warehouse") {
|
||||
try {
|
||||
const wh = await warehouseInvoicesService.get(invoice.id);
|
||||
if (wh?.bookingId) {
|
||||
navigate(`/bookings/${wh.bookingId}#warehouse-payments`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the toast below */
|
||||
}
|
||||
toast.error("This invoice's source isn't linked to a booking.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -117,27 +185,58 @@ export default function InvoiceDetailPage() {
|
||||
</Title>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</Group>
|
||||
{payable && (
|
||||
<Group gap={8} wrap="wrap">
|
||||
{canViewSource && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
onClick={viewSource}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
View source
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={downloadInvoice}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{hasReceipt && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={downloadReceipt}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
{payable && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{payMutation.isError && (
|
||||
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
|
||||
Please try again, or contact support if the problem persists.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
@@ -241,6 +340,27 @@ export default function InvoiceDetailPage() {
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
currency={invoice.currency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ const STATUS_STYLE: Record<
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||
[Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" },
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
@@ -23,19 +24,22 @@ import {
|
||||
ConsolidationPairedNotice,
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { PaymentCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
|
||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
const navigate = useNavigate();
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { view, viewer } = useFileViewer();
|
||||
@@ -72,6 +76,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
(isGeneralContract
|
||||
? status === "FULLY_EXECUTED"
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const canApproveDelivery = status === "COMPLETED";
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
@@ -93,14 +98,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
canPay &&
|
||||
!showCountdown && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
/>
|
||||
(canApproveDelivery || (canPay && !showCountdown)) && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{canApproveDelivery && (
|
||||
<ApproveDeliveryButton bookingId={booking.id} />
|
||||
)}
|
||||
{canPay && !showCountdown && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
@@ -156,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -207,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{showCountdown && (
|
||||
<PaymentDeadlineCard
|
||||
paymentDeadline={booking.paymentDeadline!}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
<PaymentCard booking={booking} pricing={pricing} />
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
@@ -248,4 +260,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Download,
|
||||
FileText,
|
||||
Receipt,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||
|
||||
// ── Pay-window countdown ─────────────────────────────────────────────────────
|
||||
|
||||
interface Remaining {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
function getRemaining(deadlineMs: number): Remaining {
|
||||
const diff = deadlineMs - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||
const total = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(total / 86400),
|
||||
hours: Math.floor((total % 86400) / 3600),
|
||||
minutes: Math.floor((total % 3600) / 60),
|
||||
seconds: total % 60,
|
||||
expired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Segment({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||
<Text fz="26px" fw={800} c="#10202F" lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.6px" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Countdown({
|
||||
deadline,
|
||||
onPay,
|
||||
paying,
|
||||
}: {
|
||||
deadline: string;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
}) {
|
||||
const deadlineMs = new Date(deadline).getTime();
|
||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getRemaining(deadlineMs));
|
||||
const interval = setInterval(() => {
|
||||
const next = getRemaining(deadlineMs);
|
||||
setRemaining(next);
|
||||
if (next.expired) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
if (remaining.expired) {
|
||||
return (
|
||||
<Text fz="13.5px" c="#6B7C8E">
|
||||
The payment window has closed. Move this booking to another schedule or
|
||||
contact support.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" px={4}>
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hrs" />
|
||||
<Segment value={remaining.minutes} label="Min" />
|
||||
<Segment value={remaining.seconds} label="Sec" />
|
||||
</Group>
|
||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||
Deadline:{" "}
|
||||
{new Date(deadline).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={14}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={17} />}
|
||||
onClick={onPay}
|
||||
loading={paying}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One card covering the whole payment story for a booking: the live pay-window
|
||||
* countdown (when open), the price breakdown, and the invoice(s) — each with a
|
||||
* link to its detail page and a download. Replaces the separate deadline +
|
||||
* breakdown cards.
|
||||
*/
|
||||
export function BookingPaymentPanel({
|
||||
booking,
|
||||
pricing,
|
||||
onPay,
|
||||
paying,
|
||||
showCountdown,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
pricing: Pricing;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
showCountdown?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const items = priceLineItems(pricing);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
// The invoice worth a prominent "Download" — the first issued one, else any.
|
||||
const primary =
|
||||
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
||||
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
||||
|
||||
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadDocument(inv.id),
|
||||
`invoice-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await invoicesService.downloadReceipt(inv.id),
|
||||
`receipt-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: paid ? "#ECF6F1" : showCountdown ? "#FEF6E6" : "#FDF3E0",
|
||||
color: paid ? "#0A6F4D" : showCountdown ? "#B07D14" : "#9A5B00",
|
||||
border: paid ? "1px solid #CDEBDD" : undefined,
|
||||
}}
|
||||
>
|
||||
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||
{paid
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
onPay={onPay}
|
||||
paying={paying}
|
||||
/>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box mt={showCountdown ? 0 : 12}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Box
|
||||
component="span"
|
||||
mt={6}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#EAF1FB",
|
||||
padding: "3px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
Adjusted by EDR
|
||||
</Box>
|
||||
)}
|
||||
{isAdjusted && booking.adjustmentReason && (
|
||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||
{booking.adjustmentReason}
|
||||
</Text>
|
||||
)}
|
||||
{paid && (
|
||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||
Paid · {fmtDate(booking.updatedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={11}>
|
||||
{items.map((it) => (
|
||||
<Group key={it.label} justify="space-between" wrap="nowrap">
|
||||
<Text fz="13px" c="#6B7C8E">
|
||||
{it.label}
|
||||
</Text>
|
||||
<Text fz="13px" fw={600} c="#10202F">
|
||||
{it.value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{isAdjusted ? "Adjusted total" : "Total"}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="space-between" align="center" mb={10}>
|
||||
<CardTitle>Invoices</CardTitle>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{invoices.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={10}>
|
||||
{invoices.map((inv) => (
|
||||
<Group key={inv.id} justify="space-between" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fz="13px"
|
||||
fw={700}
|
||||
c="#10202F"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||
>
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{titleCase(inv.type)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(primary)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{primary && primaryPaid && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(primary)}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { CreditCard, Timer } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
interface Remaining {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
function getRemaining(deadlineMs: number): Remaining {
|
||||
const diff = deadlineMs - Date.now();
|
||||
if (diff <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||
}
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86400),
|
||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
expired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Segment({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||
<Text
|
||||
fz="28px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
lh={1}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentDeadlineCard({
|
||||
paymentDeadline,
|
||||
onPay,
|
||||
paying,
|
||||
}: {
|
||||
/** ISO timestamp marking the end of the pay window. */
|
||||
paymentDeadline: string;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
}) {
|
||||
const deadlineMs = new Date(paymentDeadline).getTime();
|
||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getRemaining(deadlineMs));
|
||||
const interval = setInterval(() => {
|
||||
const next = getRemaining(deadlineMs);
|
||||
setRemaining(next);
|
||||
if (next.expired) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
||||
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
p={22}
|
||||
style={
|
||||
remaining.expired
|
||||
? undefined
|
||||
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment deadline</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: accentBg,
|
||||
color: accentFg,
|
||||
}}
|
||||
>
|
||||
<Timer size={13} />
|
||||
{remaining.expired ? "Expired" : "Pay window open"}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{remaining.expired ? (
|
||||
<Text mt={14} fz="13.5px" c="#6B7C8E">
|
||||
The payment window has closed. Move this booking to another schedule or
|
||||
contact support.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hrs" />
|
||||
<Segment value={remaining.minutes} label="Min" />
|
||||
<Segment value={remaining.seconds} label="Sec" />
|
||||
</Group>
|
||||
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
|
||||
Complete payment before the window closes to secure your slot.
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={17} />}
|
||||
onClick={onPay}
|
||||
loading={paying}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
|
||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||
Deadline:{" "}
|
||||
{new Date(paymentDeadline).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, Receipt } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
warehouseInvoicesService,
|
||||
type PortalWarehouseInvoice,
|
||||
} from "@/services/warehouse-invoices.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||
|
||||
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
||||
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||
ISSUED: { bg: "#FEF3E2", fg: "#B45309" },
|
||||
PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" },
|
||||
PAID: { bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
CANCELLED: { bg: "#EEF2F6", fg: "#64748B" },
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" };
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "3px 9px",
|
||||
borderRadius: 999,
|
||||
background: s.bg,
|
||||
color: s.fg,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
||||
* Paying them online is tracked separately (in-system demurrage/storage
|
||||
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
||||
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
||||
*/
|
||||
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-warehouse-invoices", bookingId],
|
||||
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||
});
|
||||
|
||||
if (invoices.length === 0) return null;
|
||||
|
||||
const download = async (inv: PortalWarehouseInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await warehouseInvoicesService.downloadDocument(inv.id),
|
||||
`warehouse-invoice-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Warehouse invoice PDF isn't ready yet.");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async (inv: PortalWarehouseInvoice) => {
|
||||
try {
|
||||
saveBlob(
|
||||
await warehouseInvoicesService.downloadReceipt(inv.id),
|
||||
`warehouse-receipt-${inv.invoiceNumber}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Receipt isn't available yet.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard id="warehouse-payments">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Warehouse payments</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{invoices.length} {invoices.length === 1 ? "invoice" : "invoices"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={12}>
|
||||
{invoices.map((inv) => {
|
||||
const detail = [
|
||||
inv.invoiceType?.replace(/_/g, " "),
|
||||
inv.cargoDescription ??
|
||||
inv.containerNumber ??
|
||||
inv.inventoryReference ??
|
||||
undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Group
|
||||
key={inv.id}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
border: "1px solid #EEF2F6",
|
||||
borderRadius: 12,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
<StatusPill status={inv.status} />
|
||||
</Group>
|
||||
{detail && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={4}>
|
||||
Total {money(inv.totalAmount, inv.currency)} · Balance{" "}
|
||||
{money(inv.balanceAmount, inv.currency)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock } from "lucide-react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function LineItems({ pricing }: { pricing: Pricing }) {
|
||||
@@ -107,113 +105,6 @@ export function EstimateCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentCard({
|
||||
booking,
|
||||
pricing,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
pricing: Pricing;
|
||||
}) {
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
// Customer sees the grand total plus the price breakdown that makes it up.
|
||||
// A staff adjustment, when present, overrides the computed total and is
|
||||
// flagged with an "Adjusted by EDR" badge.
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const hasItems = priceLineItems(pricing).length > 0;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
|
||||
color: paid ? "#0A6F4D" : "#9A5B00",
|
||||
border: paid ? "1px solid #CDEBDD" : undefined,
|
||||
}}
|
||||
>
|
||||
{paid && <CheckCircle2 size={13} />}
|
||||
{paid
|
||||
? "Paid"
|
||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box mt={12}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Box
|
||||
component="span"
|
||||
mt={6}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#EAF1FB",
|
||||
padding: "3px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
Adjusted by EDR
|
||||
</Box>
|
||||
)}
|
||||
{isAdjusted && booking.adjustmentReason && (
|
||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||
{booking.adjustmentReason}
|
||||
</Text>
|
||||
)}
|
||||
{paid && (
|
||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||
Paid · {fmtDate(booking.updatedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{hasItems && (
|
||||
<>
|
||||
<Divider />
|
||||
<LineItems pricing={pricing} />
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{isAdjusted ? "Adjusted total" : "Total"}
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
{/* variant="default" */}
|
||||
{/* radius={10} */}
|
||||
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
||||
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
||||
{/* > */}
|
||||
{/* Download invoice */}
|
||||
{/* </Button> */}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
// The booking payment card (countdown + breakdown + invoices + download) now
|
||||
// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and
|
||||
// changes-requested views, which only show an estimate.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||
bookingId: string;
|
||||
stopPropagation?: boolean;
|
||||
onApproved?: () => void;
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||
};
|
||||
|
||||
export function ApproveDeliveryButton({
|
||||
bookingId,
|
||||
stopPropagation,
|
||||
onApproved,
|
||||
size = "sm",
|
||||
variant = "filled",
|
||||
...props
|
||||
}: ApproveDeliveryButtonProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
onApproved?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
navigate("/signature");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (stopPropagation) event.stopPropagation();
|
||||
mutation.mutate({ id: bookingId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
size={size}
|
||||
variant={variant}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Approve delivery
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@/types/fileUploadSettings";
|
||||
import {
|
||||
bookingsService,
|
||||
type ApproveDeliveryResponse,
|
||||
BookingListFilter,
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
@@ -303,6 +304,12 @@ export const api = {
|
||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||
),
|
||||
|
||||
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
|
||||
"bookings",
|
||||
"approveDelivery",
|
||||
({ id }) => bookingsService.approveDelivery(id),
|
||||
),
|
||||
|
||||
getBookableSchedules: endpoint<
|
||||
{ originYardId?: string; destinationYardId?: string },
|
||||
Freight.BookableScheduleItem[]
|
||||
|
||||
@@ -29,12 +29,39 @@ export const invoicesService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The customer's invoices for one source record (e.g. a booking). */
|
||||
listForSource: async (
|
||||
source: string,
|
||||
sourceId: string,
|
||||
): Promise<PortalInvoice[]> => {
|
||||
const { data } = await client.get(B.MY_INVOICES, {
|
||||
params: { source, sourceId },
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** One of the customer's invoices, with its line items. */
|
||||
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The sealed invoice PDF for one of the customer's invoices. */
|
||||
downloadDocument: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** The sealed payment-receipt PDF (available once paid). */
|
||||
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||
pay: async (
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||
|
||||
/**
|
||||
* A warehouse fee invoice as the freight API projects it for the customer
|
||||
* (the historical `WarehouseFeeInvoice` view shape — a subset is used here).
|
||||
*/
|
||||
export interface PortalWarehouseInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoiceType: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: number | string;
|
||||
paidAmount: number | string;
|
||||
balanceAmount: number | string;
|
||||
issuedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
paidAt?: string | null;
|
||||
bookingId?: string | null;
|
||||
inventoryId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
inventoryReference?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
export const warehouseInvoicesService = {
|
||||
/** Warehouse fee invoices linked to a booking (via its inventory items). */
|
||||
listForBooking: async (bookingId: string): Promise<PortalWarehouseInvoice[]> => {
|
||||
const { data } = await client.get(W.FOR_BOOKING(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** A single warehouse fee invoice (carries `bookingId` for source linking). */
|
||||
get: async (id: string): Promise<PortalWarehouseInvoice> => {
|
||||
const { data } = await client.get(W.BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The sealed warehouse fee invoice PDF. */
|
||||
downloadDocument: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" });
|
||||
return data;
|
||||
},
|
||||
|
||||
/** The sealed warehouse fee payment receipt PDF (available once paid). */
|
||||
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Trigger a browser download of a Blob under `filename`. */
|
||||
export function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user