mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -196,12 +196,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
{
|
||||
label: "Self-Clearance Review",
|
||||
href: "/dashboard/contracts/ops-clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
// {
|
||||
// label: "Self-Clearance Review",
|
||||
// href: "/dashboard/contracts/ops-clearance",
|
||||
// icon: <ShieldCheck />,
|
||||
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
// },
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -192,9 +193,19 @@ export default function GlCreateBookingForm() {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||
const [withReturn, setWithReturn] = useState(false);
|
||||
const [prefilled, setPrefilled] = useState(false);
|
||||
const [priceOpen, setPriceOpen] = useState(false);
|
||||
const seededRef = useRef(false);
|
||||
const returnSeededRef = useRef(false);
|
||||
|
||||
// Seed the equipment-return toggle from the contract exactly once (also when
|
||||
// the form is prefilled from a shipment request); GL can flip it per shipment.
|
||||
useEffect(() => {
|
||||
if (!contract || returnSeededRef.current) return;
|
||||
returnSeededRef.current = true;
|
||||
setWithReturn(contract.equipmentReturn === "WITH_RETURN");
|
||||
}, [contract]);
|
||||
|
||||
const isContainer = contract?.freightType === "CONTAINER";
|
||||
const routes = useMemo(
|
||||
@@ -527,6 +538,10 @@ export default function GlCreateBookingForm() {
|
||||
scheduledDate,
|
||||
...(contractRouteId ? { contractRouteId } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
// Equipment return is a container concern — bulk keeps the contract default.
|
||||
...(isContainer
|
||||
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
@@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() {
|
||||
</StepCard>
|
||||
)}
|
||||
|
||||
{isContainer ? (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Repeat size={22} />}
|
||||
title="Equipment Return"
|
||||
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||
/>
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: withReturn ? "#CDEBDD" : "#E6ECF2",
|
||||
background: withReturn ? "#F6FBF8" : "white",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 150ms ease, background 150ms ease",
|
||||
}}
|
||||
onClick={() => setWithReturn((v) => !v)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: withReturn ? "#ECF6F1" : "#F1F4F7",
|
||||
color: withReturn ? "#0A6F4D" : "#6B7C8E",
|
||||
}}
|
||||
>
|
||||
<Repeat size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700}>
|
||||
With return
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||
{withReturn
|
||||
? "Container(s) returned to EDR after unloading."
|
||||
: "Container(s) retained by the customer after delivery."}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
size="md"
|
||||
color="edr-green"
|
||||
aria-label="With return"
|
||||
checked={withReturn}
|
||||
onChange={(e) => setWithReturn(e.currentTarget.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
</StepCard>
|
||||
) : null}
|
||||
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<CalendarDays size={22} />}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
InventoryInquiryFilter,
|
||||
InventoryStatus,
|
||||
InventoryInquiryResult,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
const SUB_STAGE_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
ASSIGNED: 'indigo',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/**
|
||||
* Expanded booking row: the booking's containers / bulk items with their
|
||||
* lifecycle stage. Shares the ['container-items', bookingId] cache with
|
||||
* ContainerItemsModal, so expanding after using the modal is instant.
|
||||
*/
|
||||
function BookingItemsExpansion({
|
||||
bookingId,
|
||||
colSpan,
|
||||
bulkFallback,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
colSpan: number;
|
||||
bulkFallback?: string;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ['container-items', bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="xs" />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" py={6}>
|
||||
{bulkFallback ?? 'No container units recorded on this booking.'}
|
||||
</Text>
|
||||
) : (
|
||||
<Table verticalSpacing={4} fz="xs" withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((i) => (
|
||||
<Table.Tr key={i.containerNumber}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>{i.containerNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
|
||||
{i.stage}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
|
||||
|
||||
/** One-click bulk actions are irreversible — make the click deliberate. */
|
||||
function ConfirmActionModal({
|
||||
action,
|
||||
onClose,
|
||||
}: {
|
||||
action: ConfirmAction | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{action?.message}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
action?.run();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{action?.confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** "3 skipped — Booking not PAID" instead of a bare count. */
|
||||
const skippedSummary = (
|
||||
skippedCount: number,
|
||||
results: Array<{ reason?: string; message?: string }>,
|
||||
): string | undefined => {
|
||||
if (!skippedCount) return undefined;
|
||||
const reason = results.find((x) => x.reason || x.message);
|
||||
return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`;
|
||||
};
|
||||
|
||||
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
||||
return unique.length === 1 ? unique[0] : '';
|
||||
@@ -860,7 +983,7 @@ function EligibleTab({
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
||||
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||
@@ -1030,7 +1153,7 @@ function EligibleTab({
|
||||
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -1043,8 +1166,6 @@ function EligibleTab({
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
@@ -1077,12 +1198,6 @@ function EligibleTab({
|
||||
{r.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.id.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customer ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.origin ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.destination ?? '—'}</Table.Td>
|
||||
@@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
|
||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
@@ -1279,7 +1396,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
@@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Mark inspected',
|
||||
message: `Mark ${selected.size} selected item(s) as inspection PASSED?`,
|
||||
confirmLabel: `Mark ${selected.size} inspected`,
|
||||
run: markInspected,
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
@@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
No received export items awaiting inspection.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
@@ -1329,8 +1454,6 @@ 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>
|
||||
<Table.Th>Container / Cargo Items</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
{rows.map((r: ReadyToLoadRow) => {
|
||||
const selectable = r.inspectionStatus !== 'PASSED';
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
@@ -1355,20 +1489,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</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>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
@@ -1392,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={18}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
@@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
/>
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1414,27 +1546,48 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
||||
);
|
||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const qc = useQueryClient();
|
||||
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
||||
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async (scheduleId: string) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||
if (!loadableIds.length) {
|
||||
throw new Error('No ready items with an allocated wagon on this train');
|
||||
}
|
||||
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
},
|
||||
});
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const autoLoad = async () => {
|
||||
const confirmLoad = async () => {
|
||||
if (!targetScheduleId) {
|
||||
toast({ variant: 'destructive', title: 'Select a train to load onto' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await loadPassed.mutateAsync(undefined);
|
||||
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
|
||||
const train = trains.find((t) => t.scheduleId === targetScheduleId);
|
||||
toast({
|
||||
title: `${r.loadedCount} items loaded`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
|
||||
description: r.skippedCount
|
||||
? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}`
|
||||
: undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTrainPickerOpen(false);
|
||||
setTargetScheduleId(null);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
@@ -1452,14 +1605,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
variant="filled"
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={loadPassed.isPending}
|
||||
disabled={rows.length === 0}
|
||||
onClick={autoLoad}
|
||||
onClick={() => setTrainPickerOpen(true)}
|
||||
>
|
||||
Auto Load Ready Items
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={trainPickerOpen}
|
||||
onClose={() => setTrainPickerOpen(false)}
|
||||
title="Load ready items onto a train"
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{trainsLoading ? (
|
||||
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
||||
) : trains.length === 0 ? (
|
||||
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
||||
No train available. Auto-loading needs a scheduled (not yet dispatched) train with
|
||||
these bookings assigned — schedule the train and allocate wagons first.
|
||||
</Alert>
|
||||
) : (
|
||||
<Select
|
||||
label="Available trains"
|
||||
placeholder="Select the train to load onto"
|
||||
data={trains.map((t) => ({
|
||||
value: t.scheduleId,
|
||||
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'} → ${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`,
|
||||
}))}
|
||||
value={targetScheduleId}
|
||||
onChange={setTargetScheduleId}
|
||||
searchable
|
||||
/>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={loadOntoTrain.isPending}
|
||||
disabled={!targetScheduleId}
|
||||
onClick={confirmLoad}
|
||||
>
|
||||
Load onto this train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
@@ -1469,22 +1666,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
No EXPORT items with inspection PASSED waiting to be loaded.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1600}>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th w={34} />
|
||||
<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>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1496,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</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>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1532,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={11}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -1563,6 +1752,8 @@ function LoadedExportTab({
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.loadedExport.queryOptions({ enabled }),
|
||||
);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const bulkDispatch = useMutation(
|
||||
api.warehouses.bulkDispatchExport.mutationOptions(),
|
||||
);
|
||||
@@ -1587,7 +1778,7 @@ function LoadedExportTab({
|
||||
const r = await bulkDispatch.mutateAsync(inventoryIds);
|
||||
toast({
|
||||
title: `${r.dispatchedCount} dispatched`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
@@ -1617,7 +1808,14 @@ function LoadedExportTab({
|
||||
variant="default"
|
||||
disabled={rows.length === 0}
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch(rows.map((r) => r.id))}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Dispatch all',
|
||||
message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`,
|
||||
confirmLabel: `Dispatch ${rows.length}`,
|
||||
run: () => dispatch(rows.map((r) => r.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
Dispatch All
|
||||
</Button>
|
||||
@@ -1627,7 +1825,14 @@ function LoadedExportTab({
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch([...selected])}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Dispatch selected',
|
||||
message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`,
|
||||
confirmLabel: `Dispatch ${selected.size}`,
|
||||
run: () => dispatch([...selected]),
|
||||
})
|
||||
}
|
||||
>
|
||||
Dispatch Selected
|
||||
</Button>
|
||||
@@ -1644,7 +1849,7 @@ function LoadedExportTab({
|
||||
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1600}>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -1658,10 +1863,9 @@ function LoadedExportTab({
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th w={34} />
|
||||
<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>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1672,7 +1876,8 @@ function LoadedExportTab({
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
{dispatchable && (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
@@ -1683,20 +1888,21 @@ function LoadedExportTab({
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</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>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1705,16 +1911,23 @@ function LoadedExportTab({
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={11}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1816,9 +2029,7 @@ function ImportTrainDetailTable({
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1852,15 +2063,9 @@ function ImportTrainDetailTable({
|
||||
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1950,6 +2155,7 @@ function ImportArriveQueueTab({
|
||||
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
||||
);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
||||
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
||||
@@ -1991,7 +2197,7 @@ function ImportArriveQueueTab({
|
||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||
const extra = [
|
||||
r.skippedCount ? `${r.skippedCount} skipped` : '',
|
||||
skippedSummary(r.skippedCount, r.results) ?? '',
|
||||
r.failedCount ? `${r.failedCount} failed` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -2087,7 +2293,14 @@ function ImportArriveQueueTab({
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
||||
onClick={() => autoUnload(t)}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Auto unload train',
|
||||
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
|
||||
confirmLabel: 'Unload train',
|
||||
run: () => autoUnload(t),
|
||||
})
|
||||
}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
</Button>
|
||||
@@ -2126,6 +2339,7 @@ function ImportArriveQueueTab({
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2146,6 +2360,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -2177,7 +2393,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
@@ -2281,7 +2497,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Mark inspected',
|
||||
message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`,
|
||||
confirmLabel: `Mark ${selected.size} inspected`,
|
||||
run: markInspected,
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
@@ -2297,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
No unloaded import items. Items appear here after Auto Unload on an arrived train.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={2000}>
|
||||
<Table.ScrollContainer minWidth={1650}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
@@ -2309,10 +2533,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
onChange={() => (allSelected ? unselectAll() : selectAll())}
|
||||
/>
|
||||
</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>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
@@ -2328,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ImportUnloadedItem) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
@@ -2337,20 +2570,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</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>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
@@ -2369,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
||||
<InventoryStatusBadge status={r.currentStatus as InventoryStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -2463,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={16}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -2491,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
bookingId={containerItemsItem?.booking?.id ?? null}
|
||||
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const bookingId = item?.booking?.id;
|
||||
// Some openers (inventory workbench) supply bookingId without the booking
|
||||
// relation — fall back to it, or the truck/container-weight queries never run.
|
||||
const bookingId = item?.booking?.id ?? item?.bookingId ?? undefined;
|
||||
// Customer self-haul trucks assigned to this booking via the portal.
|
||||
const { data: customerTrucks = [] } = useQuery({
|
||||
queryKey: ['release-customer-trucks', bookingId],
|
||||
@@ -200,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
// operator types the plate manually in the field below.
|
||||
const truckSelectOptions = assignedTruckOptions;
|
||||
// operator types the plate manually in the field below. Deduped by plate:
|
||||
// duplicate option values crash Mantine's Select.
|
||||
const truckSelectOptions = [
|
||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
||||
];
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
@@ -214,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSelectData = containerWeights.map((c) => ({
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
}));
|
||||
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||
// the same container number on two lines, so dedupe defensively.
|
||||
const containerSelectData = [
|
||||
...new Map(
|
||||
containerWeights.map((c) => [
|
||||
c.containerNumber,
|
||||
{
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface ShipmentListRow {
|
||||
summary: string;
|
||||
status: Freight.BookingRequestStatus;
|
||||
createdBookingId?: string | null;
|
||||
/** When the customer submitted the request — the queue's default sort key. */
|
||||
createdAt?: string | null;
|
||||
customerName?: string | null;
|
||||
freightKind?: "CONTAINER" | "BULK";
|
||||
hazardous?: boolean;
|
||||
reefer?: boolean;
|
||||
}
|
||||
|
||||
export type ShipmentRowAction =
|
||||
|
||||
@@ -236,8 +236,6 @@ export function useEligibleBookings(enabled = true) {
|
||||
}
|
||||
export const useBulkReceive = () =>
|
||||
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
||||
export const useLoadPassedExport = () =>
|
||||
useInventoryMutation(() => warehouseService.loadPassedExport());
|
||||
export const useBulkMarkInspected = () =>
|
||||
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -19,13 +22,29 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Banknote,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
ChevronDown,
|
||||
Coins,
|
||||
Hash,
|
||||
ListOrdered,
|
||||
ListPlus,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
Pencil,
|
||||
Phone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -41,7 +60,7 @@ import {
|
||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||
|
||||
const BODY_HINT =
|
||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
|
||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
|
||||
|
||||
interface ArticleDraft {
|
||||
id?: string;
|
||||
@@ -49,6 +68,254 @@ interface ArticleDraft {
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface PlaceholderDef {
|
||||
token: string;
|
||||
label: string;
|
||||
icon: typeof Building2;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholders the renderer fills from the contract view model
|
||||
* (contract-view-model.builder.ts). Quick row = the ones template authors
|
||||
* reach for constantly; the rest live in the grouped "More" menu.
|
||||
*/
|
||||
const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
{
|
||||
token: "{{client.companyName}}",
|
||||
label: "Client name",
|
||||
icon: Building2,
|
||||
hint: "Company name of the contracting client",
|
||||
},
|
||||
{
|
||||
token: "{{reference}}",
|
||||
label: "Reference",
|
||||
icon: Hash,
|
||||
hint: "Contract reference number",
|
||||
},
|
||||
{
|
||||
token: "{{contractDate}}",
|
||||
label: "Contract date",
|
||||
icon: CalendarDays,
|
||||
hint: "Full signature date of the contract",
|
||||
},
|
||||
{
|
||||
token: "{{contractYear}}",
|
||||
label: "Contract year",
|
||||
icon: CalendarRange,
|
||||
hint: "Year the contract is signed",
|
||||
},
|
||||
{
|
||||
token: "{{pricing.totalAmount}}",
|
||||
label: "Total price",
|
||||
icon: Banknote,
|
||||
hint: "Total contract price from the pricing schedule",
|
||||
},
|
||||
];
|
||||
|
||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||
{
|
||||
label: "Client",
|
||||
items: [
|
||||
{
|
||||
token: "{{client.companyAddress}}",
|
||||
label: "Client address",
|
||||
icon: MapPin,
|
||||
hint: "Street address of the client",
|
||||
},
|
||||
{
|
||||
token: "{{client.companyLocation}}",
|
||||
label: "Client location",
|
||||
icon: MapPin,
|
||||
hint: "Region / city of the client",
|
||||
},
|
||||
{
|
||||
token: "{{client.phone}}",
|
||||
label: "Client phone",
|
||||
icon: Phone,
|
||||
hint: "Client phone number",
|
||||
},
|
||||
{
|
||||
token: "{{client.email}}",
|
||||
label: "Client email",
|
||||
icon: Mail,
|
||||
hint: "Client email address",
|
||||
},
|
||||
{
|
||||
token: "{{client.tinNumber}}",
|
||||
label: "Client TIN",
|
||||
icon: Hash,
|
||||
hint: "Client tax identification number",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Route & cargo",
|
||||
items: [
|
||||
{
|
||||
token: "{{schedule.originLabel}}",
|
||||
label: "Origin",
|
||||
icon: MapPin,
|
||||
hint: "Origin yard / station",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.destinationLabel}}",
|
||||
label: "Destination",
|
||||
icon: MapPin,
|
||||
hint: "Destination yard / station",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.serviceType}}",
|
||||
label: "Service type",
|
||||
icon: Settings2,
|
||||
hint: "Contracted service type name",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.cargoDescription}}",
|
||||
label: "Cargo description",
|
||||
icon: Package,
|
||||
hint: "Description of the cargo",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.totalWeightVgm}}",
|
||||
label: "Total weight",
|
||||
icon: Weight,
|
||||
hint: "Total verified gross mass",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.equipmentReturn}}",
|
||||
label: "Equipment return",
|
||||
icon: RefreshCw,
|
||||
hint: "Empty-equipment return terms",
|
||||
},
|
||||
{
|
||||
token: "{{schedule.scheduledDate}}",
|
||||
label: "Scheduled date",
|
||||
icon: CalendarClock,
|
||||
hint: "Scheduled shipment date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Pricing",
|
||||
items: [
|
||||
{
|
||||
token: "{{pricing.currency}}",
|
||||
label: "Currency",
|
||||
icon: Coins,
|
||||
hint: "Payment currency (e.g. USD)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Service provider (EDR)",
|
||||
items: [
|
||||
{
|
||||
token: "{{provider.name}}",
|
||||
label: "Provider name",
|
||||
icon: Building2,
|
||||
hint: "EDR legal company name",
|
||||
},
|
||||
{
|
||||
token: "{{provider.address}}",
|
||||
label: "Provider address",
|
||||
icon: MapPin,
|
||||
hint: "EDR principal place of business",
|
||||
},
|
||||
{
|
||||
token: "{{provider.phone}}",
|
||||
label: "Provider phone",
|
||||
icon: Phone,
|
||||
hint: "EDR phone number",
|
||||
},
|
||||
{
|
||||
token: "{{provider.email}}",
|
||||
label: "Provider email",
|
||||
icon: Mail,
|
||||
hint: "EDR email address",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
...QUICK_PLACEHOLDERS,
|
||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||
];
|
||||
|
||||
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
|
||||
|
||||
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
||||
function unknownTokens(text: string): string[] {
|
||||
const found = text.match(/\{\{[^{}]+\}\}/g) ?? [];
|
||||
return [...new Set(found.filter((t) => !KNOWN_TOKENS.has(t)))];
|
||||
}
|
||||
|
||||
interface ParsedClause {
|
||||
text: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
interface ParsedBody {
|
||||
/** Set (instead of clauses) when the body is one plain paragraph. */
|
||||
paragraph?: string;
|
||||
clauses: ParsedClause[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||
* line, "- " nests a bullet under the previous clause, and a single bullet-less
|
||||
* clause renders as a plain paragraph instead of a numbered list of one.
|
||||
*/
|
||||
function parseArticleBody(body: string): ParsedBody {
|
||||
const clauses: ParsedClause[] = [];
|
||||
for (const raw of body.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("- ") && clauses.length > 0) {
|
||||
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
||||
} else {
|
||||
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
|
||||
}
|
||||
}
|
||||
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
||||
return { paragraph: clauses[0].text, clauses: [] };
|
||||
}
|
||||
return { clauses };
|
||||
}
|
||||
|
||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||
function HighlightedText({ text }: { text: string }) {
|
||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
/^\{\{[^{}]+\}\}$/.test(part) ? (
|
||||
<Text
|
||||
key={i}
|
||||
component="span"
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={KNOWN_TOKENS.has(part) ? "edr-green.8" : "red.7"}
|
||||
px={4}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
background: KNOWN_TOKENS.has(part)
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-red-0)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContractTemplateEditorPage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const { data: template, isLoading } = useContractTemplate(code);
|
||||
@@ -79,15 +346,12 @@ export default function ContractTemplateEditorPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const saveArticle = () => {
|
||||
const saveArticle = (values: { title: string; body: string }) => {
|
||||
if (!articleDraft) return;
|
||||
if (articleDraft.id) {
|
||||
updateArticle.mutate({
|
||||
articleId: articleDraft.id,
|
||||
payload: { title: articleDraft.title, body: articleDraft.body },
|
||||
});
|
||||
updateArticle.mutate({ articleId: articleDraft.id, payload: values });
|
||||
} else {
|
||||
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
|
||||
addArticle.mutate(values);
|
||||
}
|
||||
setArticleDraft(null);
|
||||
};
|
||||
@@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(articleDraft)}
|
||||
onClose={() => setArticleDraft(null)}
|
||||
title={articleDraft?.id ? "Edit article" : "Add article"}
|
||||
size="xl"
|
||||
>
|
||||
{articleDraft && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Article title"
|
||||
placeholder="e.g. Obligations of the Client"
|
||||
value={articleDraft.title}
|
||||
onChange={(event) =>
|
||||
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Article body"
|
||||
description={BODY_HINT}
|
||||
value={articleDraft.body}
|
||||
onChange={(event) =>
|
||||
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
|
||||
}
|
||||
autosize
|
||||
minRows={12}
|
||||
maxRows={24}
|
||||
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setArticleDraft(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={
|
||||
articleDraft.title.trim().length < 2 ||
|
||||
articleDraft.body.trim().length < 2
|
||||
}
|
||||
loading={addArticle.isPending || updateArticle.isPending}
|
||||
onClick={saveArticle}
|
||||
>
|
||||
{articleDraft.id ? "Save changes" : "Add article"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
{articleDraft && (
|
||||
<ArticleEditorModal
|
||||
initial={articleDraft}
|
||||
saving={addArticle.isPending || updateArticle.isPending}
|
||||
onClose={() => setArticleDraft(null)}
|
||||
onSave={saveArticle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
@@ -364,6 +587,269 @@ export default function ContractTemplateEditorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ArticleEditorModalProps {
|
||||
initial: ArticleDraft;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: { title: string; body: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich add/edit article editor: placeholder buttons insert at the text cursor
|
||||
* of whichever field (title or body) was focused last, with a live preview of
|
||||
* the numbered clauses exactly as the renderer lays them out.
|
||||
*/
|
||||
function ArticleEditorModal({
|
||||
initial,
|
||||
saving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ArticleEditorModalProps) {
|
||||
const [title, setTitle] = useState(initial.title);
|
||||
const [body, setBody] = useState(initial.body);
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Placeholders drop into whichever field held the cursor last (body default).
|
||||
const lastFocused = useRef<"title" | "body">("body");
|
||||
|
||||
const insertAtCursor = (snippet: string) => {
|
||||
const isTitle = lastFocused.current === "title";
|
||||
const el = isTitle ? titleRef.current : bodyRef.current;
|
||||
const value = isTitle ? title : body;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? start;
|
||||
const next = value.slice(0, start) + snippet + value.slice(end);
|
||||
if (isTitle) setTitle(next);
|
||||
else setBody(next);
|
||||
// Refocus and place the caret right after the inserted snippet once the
|
||||
// controlled re-render has flushed.
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + snippet.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
const insertLinePrefix = (prefix: string) => {
|
||||
const el = bodyRef.current;
|
||||
const start = el?.selectionStart ?? body.length;
|
||||
// Start the snippet on its own line unless the caret already is.
|
||||
const needsNewline = start > 0 && body[start - 1] !== "\n";
|
||||
lastFocused.current = "body";
|
||||
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
|
||||
};
|
||||
|
||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||
const clauseCount = parsed.paragraph ? 1 : parsed.clauses.length;
|
||||
const unknown = useMemo(
|
||||
() => unknownTokens(`${title}\n${body}`),
|
||||
[title, body],
|
||||
);
|
||||
const canSave = title.trim().length >= 2 && body.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={initial.id ? "Edit article" : "Add article"}
|
||||
size="min(1120px, 95vw)"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* ── Editor ─────────────────────────────────────────────────── */}
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
ref={titleRef}
|
||||
label="Article title"
|
||||
placeholder="e.g. Obligations of the Client"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
onFocus={() => (lastFocused.current = "title")}
|
||||
required
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Insert placeholder
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{QUICK_PLACEHOLDERS.map(({ token, label, icon: Icon, hint }) => (
|
||||
<Tooltip key={token} label={hint} withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Icon size={13} />}
|
||||
// Keep the field's focus/caret alive so insertion lands
|
||||
// where the user was typing.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertAtCursor(token)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Menu shadow="md" width={300} position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Plus size={13} />}
|
||||
rightSection={<ChevronDown size={13} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
More
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown mah={340} style={{ overflowY: "auto" }}>
|
||||
{MORE_PLACEHOLDER_GROUPS.map((group) => (
|
||||
<Box key={group.label}>
|
||||
<Menu.Label>{group.label}</Menu.Label>
|
||||
{group.items.map(({ token, label, icon: Icon, hint }) => (
|
||||
<Menu.Item
|
||||
key={token}
|
||||
leftSection={<Icon size={14} />}
|
||||
onClick={() => insertAtCursor(token)}
|
||||
>
|
||||
<Text size="sm">{label}</Text>
|
||||
<Text size="xs" c="dimmed" title={hint}>
|
||||
{token}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Tooltip label="Start a new numbered clause" withArrow>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListOrdered size={13} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertLinePrefix("")}
|
||||
>
|
||||
New clause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Nest a bullet under the previous clause" withArrow>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListPlus size={13} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertLinePrefix("- ")}
|
||||
>
|
||||
Bullet
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Textarea
|
||||
ref={bodyRef}
|
||||
label="Article body"
|
||||
description={BODY_HINT}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.currentTarget.value)}
|
||||
onFocus={() => (lastFocused.current = "body")}
|
||||
autosize
|
||||
minRows={12}
|
||||
maxRows={22}
|
||||
styles={{
|
||||
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
||||
}}
|
||||
required
|
||||
/>
|
||||
|
||||
{unknown.length > 0 && (
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||
<Text size="xs" c="red.7">
|
||||
Unknown placeholder{unknown.length > 1 ? "s" : ""}{" "}
|
||||
{unknown.join(", ")} — the generator won't fill{" "}
|
||||
{unknown.length > 1 ? "these" : "this"}. Pick from the Insert
|
||||
placeholder buttons instead.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* ── Live preview ───────────────────────────────────────────── */}
|
||||
<Paper withBorder radius="md" p="md" className="self-start lg:sticky lg:top-0">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Live preview
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{clauseCount} clause{clauseCount !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah="60vh">
|
||||
{title.trim() || clauseCount > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{title.trim() && (
|
||||
<Title order={5}>
|
||||
<HighlightedText text={title} />
|
||||
</Title>
|
||||
)}
|
||||
{parsed.paragraph && (
|
||||
<Text size="sm">
|
||||
<HighlightedText text={parsed.paragraph} />
|
||||
</Text>
|
||||
)}
|
||||
{parsed.clauses.map((clause, i) => (
|
||||
<Box key={i}>
|
||||
<Text size="sm">
|
||||
<Text component="span" fw={600} c="edr-green.7">
|
||||
{i + 1}.{" "}
|
||||
</Text>
|
||||
<HighlightedText text={clause.text} />
|
||||
</Text>
|
||||
{clause.bullets.length > 0 && (
|
||||
<Stack gap={2} mt={2} pl="lg">
|
||||
{clause.bullets.map((bullet, j) => (
|
||||
<Text key={j} size="sm" c="dimmed">
|
||||
• <HighlightedText text={bullet} />
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
Start typing — the article renders here exactly as it will
|
||||
appear in the contract.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
onClick={() => onSave({ title: title.trim(), body: body.trim() })}
|
||||
>
|
||||
{initial.id ? "Save changes" : "Add article"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentDetailsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -2,17 +2,25 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
|
||||
import {
|
||||
Boxes,
|
||||
Clock,
|
||||
Container,
|
||||
Eye,
|
||||
FileText,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||
@@ -25,10 +33,10 @@ const DIRECTION_LABEL: Record<string, string> = {
|
||||
INTERCITY: "Intercity",
|
||||
};
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "edr-green",
|
||||
EXPORT: "teal",
|
||||
INTERCITY: "lime",
|
||||
const DIRECTION_DOT: Record<string, string> = {
|
||||
IMPORT: "var(--mantine-color-blue-5)",
|
||||
EXPORT: "var(--mantine-color-violet-5)",
|
||||
INTERCITY: "var(--mantine-color-orange-5)",
|
||||
};
|
||||
|
||||
function templateDirection(code: ContractTemplate["code"]): string {
|
||||
@@ -39,6 +47,14 @@ function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractTemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: templates, isLoading } = useContractTemplates();
|
||||
@@ -53,94 +69,20 @@ export default function ContractTemplatesPage() {
|
||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center h={320}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{(templates ?? []).map((template) => {
|
||||
const direction = templateDirection(template.code);
|
||||
return (
|
||||
<Card key={template.code} withBorder radius="xl" padding="lg">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
{isBulk(template.code) ? (
|
||||
<Boxes size={24} />
|
||||
) : (
|
||||
<Container size={24} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={DIRECTION_COLOR[direction] ?? "edr-green"}
|
||||
>
|
||||
{DIRECTION_LABEL[direction] ?? direction}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{isBulk(template.code) ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
{!template.isActive && (
|
||||
<Badge variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{template.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
||||
{template.description || template.documentTitle}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group gap="xs" mt="auto">
|
||||
<FileSignature size={14} className="text-edr-primary" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{template.articles.length} articles · updated{" "}
|
||||
{new Date(template.updatedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setPreviewCode(template.code)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Pencil size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||
}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
: (templates ?? []).map((template) => (
|
||||
<TemplateCard
|
||||
key={template.code}
|
||||
template={template}
|
||||
onPreview={() => setPreviewCode(template.code)}
|
||||
onEdit={() =>
|
||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<TemplatePreviewModal
|
||||
code={previewCode}
|
||||
@@ -150,3 +92,151 @@ export default function ContractTemplatesPage() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onPreview,
|
||||
onEdit,
|
||||
}: {
|
||||
template: ContractTemplate;
|
||||
onPreview: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
padding={0}
|
||||
className="group flex flex-col overflow-hidden transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<Stack gap="md" p="lg" style={{ flex: 1 }}>
|
||||
{/* Kicker row: muted icon well + category label + state */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="gray" c="gray.6">
|
||||
{bulk ? (
|
||||
<Boxes size={20} strokeWidth={1.75} />
|
||||
) : (
|
||||
<Container size={20} strokeWidth={1.75} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Group gap={7} wrap="nowrap">
|
||||
<Box
|
||||
w={7}
|
||||
h={7}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
||||
{bulk ? "Bulk" : "Container"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Name + description */}
|
||||
<div>
|
||||
<Text fw={600} size="md" lh={1.35}>
|
||||
{template.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={2} mt={4} lh={1.5}>
|
||||
{template.description || template.documentTitle}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Meta stats */}
|
||||
<Group gap="lg" mt="auto">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<FileText size={13} className="text-gray-400" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{template.articles.length} article
|
||||
{template.articles.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Clock size={13} className="text-gray-400" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Updated {formatUpdated(template.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Footer actions, separated by a hairline */}
|
||||
<Box
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={onPreview}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Pencil size={14} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCardSkeleton() {
|
||||
return (
|
||||
<Card withBorder radius="lg" padding={0} className="overflow-hidden">
|
||||
<Stack gap="md" p="lg">
|
||||
<Group gap="sm">
|
||||
<Skeleton height={40} width={40} radius="md" />
|
||||
<Skeleton height={10} width={120} radius="xl" />
|
||||
</Group>
|
||||
<div>
|
||||
<Skeleton height={14} width="70%" radius="xl" />
|
||||
<Skeleton height={10} width="95%" radius="xl" mt={10} />
|
||||
<Skeleton height={10} width="60%" radius="xl" mt={6} />
|
||||
</div>
|
||||
<Group gap="lg">
|
||||
<Skeleton height={10} width={70} radius="xl" />
|
||||
<Skeleton height={10} width={110} radius="xl" />
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={26} width={90} radius="md" />
|
||||
<Skeleton height={26} width={110} radius="md" />
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,26 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
CloseButton,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
FilterX,
|
||||
Inbox,
|
||||
PackageSearch,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -41,6 +53,17 @@ const fmtDate = (iso?: string | null) =>
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const fmtDateTime = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers
|
||||
@@ -56,10 +79,46 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
return "—";
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.BookingRequestStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
PENDING: { label: "Pending", color: "yellow" },
|
||||
ACCEPTED: { label: "Accepted", color: "edr-green" },
|
||||
REJECTED: { label: "Rejected", color: "red" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
type StatusFilter = "ALL" | Freight.BookingRequestStatus;
|
||||
type CargoFilter = "ALL" | "CONTAINER" | "BULK";
|
||||
type SortKey =
|
||||
| "submitted-desc"
|
||||
| "submitted-asc"
|
||||
| "preferred-asc"
|
||||
| "preferred-desc"
|
||||
| "reference";
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortKey; label: string }> = [
|
||||
{ value: "submitted-desc", label: "Newest first" },
|
||||
{ value: "submitted-asc", label: "Oldest first" },
|
||||
{ value: "preferred-asc", label: "Preferred date (soonest)" },
|
||||
{ value: "preferred-desc", label: "Preferred date (latest)" },
|
||||
{ value: "reference", label: "Reference A–Z" },
|
||||
];
|
||||
|
||||
const time = (iso?: string | null) => (iso ? new Date(iso).getTime() : 0);
|
||||
|
||||
export default function ShipmentRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("PENDING");
|
||||
const [cargo, setCargo] = useState<CargoFilter>("ALL");
|
||||
const [preferredFrom, setPreferredFrom] = useState<Date | null>(null);
|
||||
const [preferredTo, setPreferredTo] = useState<Date | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("submitted-desc");
|
||||
|
||||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||
@@ -80,26 +139,132 @@ export default function ShipmentRequestsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const allRows = useMemo<ShipmentListRow[]>(
|
||||
() =>
|
||||
(data ?? []).map((r) => {
|
||||
const lines = r.requestedLines ?? {};
|
||||
return {
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractId: r.contractId,
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(lines),
|
||||
status: r.status,
|
||||
createdBookingId: r.createdBookingId,
|
||||
createdAt: r.createdAt,
|
||||
customerName: r.contract?.company?.name ?? null,
|
||||
freightKind: lines.containers?.length
|
||||
? "CONTAINER"
|
||||
: lines.bulk
|
||||
? "BULK"
|
||||
: r.contract?.freightType === "BULK"
|
||||
? "BULK"
|
||||
: "CONTAINER",
|
||||
hazardous:
|
||||
(lines.containers ?? []).some((c) => (c.hazardousQuantity ?? 0) > 0) ||
|
||||
(lines.bulk?.hazardousQuantity ?? 0) > 0,
|
||||
reefer: (lines.containers ?? []).some(
|
||||
(c) => (c.reeferQuantity ?? 0) > 0,
|
||||
),
|
||||
};
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
// Status counts always reflect the whole queue so the segmented control
|
||||
// reads as a live overview, independent of the other filters.
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<StatusFilter, number> = {
|
||||
ALL: allRows.length,
|
||||
PENDING: 0,
|
||||
ACCEPTED: 0,
|
||||
REJECTED: 0,
|
||||
CANCELLED: 0,
|
||||
};
|
||||
allRows.forEach((r) => {
|
||||
c[r.status] += 1;
|
||||
});
|
||||
return c;
|
||||
}, [allRows]);
|
||||
|
||||
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||
const all = (data ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractId: r.contractId,
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(r.requestedLines ?? {}),
|
||||
status: r.status,
|
||||
createdBookingId: r.createdBookingId,
|
||||
}));
|
||||
let out = allRows;
|
||||
|
||||
if (status !== "ALL") out = out.filter((r) => r.status === status);
|
||||
if (cargo !== "ALL") out = out.filter((r) => r.freightKind === cargo);
|
||||
|
||||
// Preferred-date range: rows without a preferred day drop out once a bound
|
||||
// is set — a date filter that keeps dateless rows reads as broken.
|
||||
if (preferredFrom || preferredTo) {
|
||||
const from = preferredFrom ? preferredFrom.getTime() : -Infinity;
|
||||
const to = preferredTo
|
||||
? preferredTo.getTime() + 24 * 60 * 60 * 1000 - 1
|
||||
: Infinity;
|
||||
out = out.filter((r) => {
|
||||
if (!r.scheduledDate) return false;
|
||||
const t = time(r.scheduledDate);
|
||||
return t >= from && t <= to;
|
||||
});
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.contractReference.toLowerCase().includes(q) ||
|
||||
r.summary.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, query]);
|
||||
if (q) {
|
||||
out = out.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.contractReference.toLowerCase().includes(q) ||
|
||||
(r.customerName ?? "").toLowerCase().includes(q) ||
|
||||
r.summary.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...out];
|
||||
switch (sort) {
|
||||
case "submitted-asc":
|
||||
sorted.sort((a, b) => time(a.createdAt) - time(b.createdAt));
|
||||
break;
|
||||
case "preferred-asc":
|
||||
// Requests without a preferred day sink to the bottom in both orders.
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
(a.scheduledDate ? time(a.scheduledDate) : Infinity) -
|
||||
(b.scheduledDate ? time(b.scheduledDate) : Infinity),
|
||||
);
|
||||
break;
|
||||
case "preferred-desc":
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
(b.scheduledDate ? time(b.scheduledDate) : -Infinity) -
|
||||
(a.scheduledDate ? time(a.scheduledDate) : -Infinity),
|
||||
);
|
||||
break;
|
||||
case "reference":
|
||||
sorted.sort((a, b) => a.reference.localeCompare(b.reference));
|
||||
break;
|
||||
default:
|
||||
// Newest submitted on top.
|
||||
sorted.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
||||
}
|
||||
return sorted;
|
||||
}, [allRows, status, cargo, preferredFrom, preferredTo, query, sort]);
|
||||
|
||||
const filtersActive =
|
||||
query.trim() !== "" ||
|
||||
status !== "PENDING" ||
|
||||
cargo !== "ALL" ||
|
||||
preferredFrom !== null ||
|
||||
preferredTo !== null ||
|
||||
sort !== "submitted-desc";
|
||||
|
||||
const clearFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("PENDING");
|
||||
setCargo("ALL");
|
||||
setPreferredFrom(null);
|
||||
setPreferredTo(null);
|
||||
setSort("submitted-desc");
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||
() => [
|
||||
@@ -108,9 +273,14 @@ export default function ShipmentRequestsPage() {
|
||||
header: "Request",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Submitted {fmtDateTime(row.original.createdAt)}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -118,9 +288,16 @@ export default function ShipmentRequestsPage() {
|
||||
header: "Contract",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="gray.7">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text size="sm" c="gray.7">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
{row.original.customerName ? (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{row.original.customerName}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -128,9 +305,21 @@ export default function ShipmentRequestsPage() {
|
||||
header: "Requested",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{row.original.summary}
|
||||
</Badge>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{row.original.summary}
|
||||
</Badge>
|
||||
{row.original.hazardous ? (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.original.reefer ? (
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
Reefer
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -141,6 +330,19 @@ export default function ShipmentRequestsPage() {
|
||||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||
@@ -193,6 +395,8 @@ export default function ShipmentRequestsPage() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const hasAnyRequests = allRows.length > 0;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -206,7 +410,7 @@ export default function ShipmentRequestsPage() {
|
||||
radius="sm"
|
||||
leftSection={<PackageSearch size={13} />}
|
||||
>
|
||||
{rows.length} pending
|
||||
{counts.PENDING} pending
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
@@ -223,16 +427,108 @@ export default function ShipmentRequestsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
radius="md"
|
||||
maw={360}
|
||||
placeholder="Search request, contract, cargo…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Paper withBorder radius="lg" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
radius="md"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
placeholder="Search request, contract, customer, cargo…"
|
||||
leftSection={<Search size={15} />}
|
||||
rightSection={
|
||||
query ? (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setQuery("")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={150}
|
||||
value={cargo}
|
||||
onChange={(v) => setCargo((v as CargoFilter) ?? "ALL")}
|
||||
data={[
|
||||
{ value: "ALL", label: "All cargo" },
|
||||
{ value: "CONTAINER", label: "Containers" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
aria-label="Cargo type"
|
||||
/>
|
||||
<DateInput
|
||||
radius="md"
|
||||
w={150}
|
||||
placeholder="Preferred from"
|
||||
value={preferredFrom}
|
||||
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
|
||||
maxDate={preferredTo ?? undefined}
|
||||
clearable
|
||||
aria-label="Preferred date from"
|
||||
/>
|
||||
<DateInput
|
||||
radius="md"
|
||||
w={150}
|
||||
placeholder="Preferred to"
|
||||
value={preferredTo}
|
||||
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
|
||||
minDate={preferredFrom ?? undefined}
|
||||
clearable
|
||||
aria-label="Preferred date to"
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={215}
|
||||
leftSection={<ArrowUpDown size={14} />}
|
||||
value={sort}
|
||||
onChange={(v) => setSort((v as SortKey) ?? "submitted-desc")}
|
||||
data={SORT_OPTIONS}
|
||||
allowDeselect={false}
|
||||
aria-label="Sort by"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||
<SegmentedControl
|
||||
radius="md"
|
||||
size="xs"
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as StatusFilter)}
|
||||
data={[
|
||||
{ value: "ALL", label: `All · ${counts.ALL}` },
|
||||
{ value: "PENDING", label: `Pending · ${counts.PENDING}` },
|
||||
{ value: "ACCEPTED", label: `Accepted · ${counts.ACCEPTED}` },
|
||||
{ value: "REJECTED", label: `Rejected · ${counts.REJECTED}` },
|
||||
{ value: "CANCELLED", label: `Cancelled · ${counts.CANCELLED}` },
|
||||
]}
|
||||
/>
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{rows.length} of {allRows.length} request
|
||||
{allRows.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
{filtersActive ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<FilterX size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{rows.length === 0 && !isLoading && !isError ? (
|
||||
<Box
|
||||
py={56}
|
||||
style={{
|
||||
@@ -242,9 +538,28 @@ export default function ShipmentRequestsPage() {
|
||||
}}
|
||||
>
|
||||
<Inbox size={26} className="text-muted-foreground" />
|
||||
<Text c="dimmed" mt="sm">
|
||||
No pending shipment requests.
|
||||
</Text>
|
||||
{hasAnyRequests ? (
|
||||
<>
|
||||
<Text c="dimmed" mt="sm">
|
||||
No requests match the current filters.
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
mt="xs"
|
||||
leftSection={<FilterX size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Text c="dimmed" mt="sm">
|
||||
No shipment requests yet.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<DataTable
|
||||
|
||||
@@ -101,7 +101,6 @@ import type {
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
PayInvoicePayload,
|
||||
@@ -1158,14 +1157,6 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
loadPassedExport: endpoint<void, LoadPassedExportResult>(
|
||||
"warehouse-inventory",
|
||||
"load-passed-export",
|
||||
() => warehouseService.loadPassedExport().then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
||||
"warehouse-inventory",
|
||||
"bulk-mark-inspected",
|
||||
|
||||
@@ -43,8 +43,18 @@ export interface SaveRoutePayload {
|
||||
* Human-readable route label: yard names, not yard codes. Staff read
|
||||
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
||||
* code only when a yard has no label.
|
||||
*
|
||||
* When milestones are present they ARE the full ordered corridor (origin first,
|
||||
* destination last), so the label shows every stop:
|
||||
* "Addis Ababa → Adama → Dire Dawa".
|
||||
*/
|
||||
export function formatRouteLabel(route: RouteRecord): string {
|
||||
const stops = [...(route.milestones ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
if (stops.length >= 2) return stops.join(' → ');
|
||||
|
||||
const origin =
|
||||
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||
const dest =
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
EligibleBooking,
|
||||
BulkReceivePayload,
|
||||
BulkReceiveResult,
|
||||
LoadPassedExportResult,
|
||||
BulkInspectPayload,
|
||||
BulkInspectResult,
|
||||
ReadyToLoadRow,
|
||||
@@ -300,8 +299,6 @@ export const warehouseService = {
|
||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||
receiveBulk: (payload: BulkReceivePayload) =>
|
||||
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
||||
loadPassedExport: () =>
|
||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||
receivedExport: () =>
|
||||
|
||||
@@ -201,6 +201,11 @@ export interface BookingDetail {
|
||||
latestChangeRequestNote?: string | null;
|
||||
nextStep?: BookingNextStep | null;
|
||||
paymentReceipt?: InAppPaymentReceipt;
|
||||
/** Phased-clearance fields the ET/DJ queue rows surface (GENERAL customs bookings). */
|
||||
clearanceCurrentPhase?: string | null;
|
||||
roHoldReason?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
preClearanceFinalizedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// customer?: BookingNamedRef & { companyName?: string };
|
||||
|
||||
@@ -241,11 +241,10 @@ export default function ContractDetailPage() {
|
||||
});
|
||||
// Intercity contracts are never window-gated: the shipment rides a passing
|
||||
// import/export train that staff assign later, so booking is always open.
|
||||
// GENERAL contracts are also not gated at creation — the booking enters the
|
||||
// per-booking clearance gate first and picks its shipment day at proceed time.
|
||||
// ONE_TIME and GENERAL contracts are both gated — booking is only possible
|
||||
// while a window on the contract's lane is open.
|
||||
const bookingWindowOpen =
|
||||
contract?.tradeDirection === "DOMESTIC" ||
|
||||
contract?.contractKind === "GENERAL" ||
|
||||
hasOpenWindow(bookingWindows);
|
||||
|
||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -144,14 +146,12 @@ export default function NewShipmentPage() {
|
||||
|
||||
// Coarse gate: if the customer deep-links here while no booking window is
|
||||
// open, show the same closed-state notice as the contract page instead of the
|
||||
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
||||
// are never window-gated — the shipment rides a passing train that staff
|
||||
// pick at finalize time, so booking is always open. GENERAL contracts are not
|
||||
// gated at creation either: the booking enters per-booking clearance first
|
||||
// and picks its shipment day at proceed time.
|
||||
// form. Still allowed the moment any window isOpenNow. Applies to ONE_TIME
|
||||
// and GENERAL alike. Intercity contracts are never window-gated — the
|
||||
// shipment rides a passing train that staff pick at finalize time, so
|
||||
// booking is always open.
|
||||
if (
|
||||
contract.tradeDirection !== "DOMESTIC" &&
|
||||
contract.contractKind !== "GENERAL" &&
|
||||
!hasOpenWindow(bookingWindows)
|
||||
) {
|
||||
return (
|
||||
@@ -230,7 +230,12 @@ function NewShipmentBookingForm({
|
||||
);
|
||||
|
||||
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
||||
defaultValues: initialShipmentFormValues,
|
||||
defaultValues: {
|
||||
...initialShipmentFormValues,
|
||||
// Seed the equipment-return toggle from the contract; the customer can
|
||||
// still flip it per shipment.
|
||||
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||
},
|
||||
resolver: zodResolver(
|
||||
createShipmentFormSchema({
|
||||
isContainer: contract.freightType === "CONTAINER",
|
||||
@@ -276,8 +281,10 @@ function NewShipmentBookingForm({
|
||||
...(values.scheduledDate
|
||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||
: {}),
|
||||
// Equipment return is a container concern — bulk keeps the contract default.
|
||||
...(isContainer
|
||||
? {
|
||||
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
|
||||
containers: values.containers
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -405,6 +412,9 @@ function NewShipmentBookingForm({
|
||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||
<RouteStep form={form} contract={contract} routes={routes} />
|
||||
<CargoStep form={form} contract={contract} />
|
||||
{contract.freightType === "CONTAINER" && (
|
||||
<EquipmentReturnStep form={form} />
|
||||
)}
|
||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||
<NotesSection form={form} />
|
||||
</Stack>
|
||||
@@ -1187,6 +1197,78 @@ function CargoStep({
|
||||
);
|
||||
}
|
||||
|
||||
function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Repeat size={22} />}
|
||||
title="Equipment Return"
|
||||
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||
/>
|
||||
<Controller
|
||||
name="withReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
const on = field.value ?? false;
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: on ? "#CDEBDD" : "#E6ECF2",
|
||||
background: on ? "#F6FBF8" : "white",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 150ms ease, background 150ms ease",
|
||||
}}
|
||||
onClick={() => field.onChange(!on)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: on ? "#ECF6F1" : "#F1F4F7",
|
||||
color: on ? "#0A6F4D" : "#6B7C8E",
|
||||
}}
|
||||
>
|
||||
<Repeat size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
With return
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||
{on
|
||||
? "Container(s) returned to EDR after unloading."
|
||||
: "Container(s) retained by you after delivery."}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Switch
|
||||
size="md"
|
||||
color="edr-green"
|
||||
aria-label="With return"
|
||||
checked={on}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSection({ form }: { form: ShipmentForm }) {
|
||||
return (
|
||||
<StepCard>
|
||||
|
||||
@@ -61,11 +61,16 @@ export default function NewShipmentRequestPage() {
|
||||
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
const route = contract.routes?.[0];
|
||||
// GENERAL customs contracts: GL schedules the shipment during clearance —
|
||||
// the customer only states the quantity, never picks a date.
|
||||
const hasCustoms =
|
||||
contract.contractKind === "GENERAL" &&
|
||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: scheduledDate || undefined,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -104,19 +109,26 @@ export default function NewShipmentRequestPage() {
|
||||
|
||||
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
|
||||
<Stack gap="md">
|
||||
<DatePickerInput
|
||||
label="Preferred shipment date"
|
||||
placeholder="Pick a date"
|
||||
leftSection={<CalendarDays size={16} />}
|
||||
minDate={new Date().toISOString().slice(0, 10)}
|
||||
value={scheduledDate || null}
|
||||
onChange={(v) => setScheduledDate(v ?? "")}
|
||||
radius="md"
|
||||
popoverProps={{ withinPortal: true }}
|
||||
/>
|
||||
{!hasCustoms && (
|
||||
<DatePickerInput
|
||||
label="Preferred shipment date"
|
||||
placeholder="Pick a date"
|
||||
leftSection={<CalendarDays size={16} />}
|
||||
minDate={new Date().toISOString().slice(0, 10)}
|
||||
value={scheduledDate || null}
|
||||
onChange={(v) => setScheduledDate(v ?? "")}
|
||||
radius="md"
|
||||
popoverProps={{ withinPortal: true }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
||||
description={
|
||||
hasCustoms
|
||||
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||
: undefined
|
||||
}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
|
||||
@@ -58,6 +58,9 @@ const containerLineSchema = z.object({
|
||||
const shipmentFormBase = z.object({
|
||||
contractRouteId: z.string().default(""),
|
||||
scheduledDate: z.string().default(""),
|
||||
// Container contracts only: return the empty container(s) to EDR after
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
containers: z.array(containerLineSchema).default([]),
|
||||
cargoWeightTons: z.string().default(""),
|
||||
itemCount: z.string().default(""),
|
||||
@@ -210,6 +213,7 @@ export type ShipmentFormInputValues = z.input<typeof shipmentFormSchema>;
|
||||
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
contractRouteId: "",
|
||||
scheduledDate: "",
|
||||
withReturn: false,
|
||||
containers: [],
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
@@ -229,6 +233,7 @@ export const shipmentStepFields: Record<
|
||||
"itemCount",
|
||||
"bulkHazardousQuantity",
|
||||
"bulkReeferQuantity",
|
||||
"withReturn",
|
||||
],
|
||||
2: ["scheduledDate"],
|
||||
3: ["notes"],
|
||||
|
||||
Reference in New Issue
Block a user