Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit

This commit is contained in:
marshal
2026-09-02 22:38:15 +00:00
425 changed files with 30919 additions and 3238 deletions

View File

@@ -0,0 +1,110 @@
import { Alert, Loader, Select, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react";
import { useMemo } from "react";
import type { ETradeBusinessOption } from "@edr/types";
import { api } from "@/services/api";
/**
* The eTrade business licences held under the signed-in company's TIN, cached
* for the session. Fetching goes out to eTrade, which is slow and regularly
* down, so this must not refetch on every mount of every role card.
*/
export function useEtradeBusinesses() {
return useQuery({
...api.companies.listEtradeBusinesses.queryOptions(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
}
/** One licence, as it reads in the dropdown: trade name, then what it licenses. */
export function businessLabel(b: ETradeBusinessOption): string {
const name = b.tradeName || "(no trade name on this licence)";
return b.activity ? `${name}${b.activity}` : name;
}
interface EtradeBusinessSelectProps {
/** Currently attached licence number, if any. */
value: string | null;
onChange: (licenceNumber: string) => void;
label?: string;
error?: string;
disabled?: boolean;
}
/**
* Which of the TIN's eTrade businesses a company profile operates as.
*
* A TIN routinely holds a dozen licences split by activity — export of coffee,
* freight forwarding, import of vehicles — so the role a customer signs up for
* corresponds to one specific business, not to the company as a whole. The same
* business may legitimately back several roles, so nothing is filtered out
* because it is already in use elsewhere.
*/
export default function EtradeBusinessSelect({
value,
onChange,
label = "Which business does this profile operate as?",
error,
disabled,
}: EtradeBusinessSelectProps) {
const { data, isLoading, isError } = useEtradeBusinesses();
const options = useMemo(
() =>
(data ?? []).map((b) => ({
value: b.licenceNumber,
label: businessLabel(b),
})),
[data],
);
if (isLoading) {
return (
<Stack gap={4}>
<Text size="sm" c="edr-muted">
{label}
</Text>
<Loader size="sm" color="edr-green" />
</Stack>
);
}
if (isError) {
return (
<Alert color="yellow" icon={<AlertCircle size={16} />}>
We couldn't reach eTrade to list your business licences. Try again in a
moment.
</Alert>
);
}
if (options.length === 0) {
return (
<Text size="xs" c="edr-muted">
eTrade lists no business licence under your TIN, so there is nothing to
attach here.
</Text>
);
}
return (
<Select
label={label}
placeholder="Select a business licence"
data={options}
value={value}
onChange={(v) => v && onChange(v)}
error={error}
disabled={disabled}
searchable={options.length > 8}
nothingFoundMessage="No matching licence"
// The licence number is what identifies the business; the trade name
// repeats across licences, so it alone is not enough to tell them apart.
description={value ?? undefined}
comboboxProps={{ withinPortal: true }}
/>
);
}

View File

@@ -428,6 +428,7 @@ export default function OnboardingWizardDialog({
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
etradeBusiness: p.etradeBusiness ?? null,
}));
// The active step across the whole journey, driving the header + progress pill.

View File

@@ -1,9 +1,13 @@
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Fragment } from "react";
import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import type { ETradeBusinessOption, IFileUploadSetting } from "@edr/types";
import EtradeBusinessSelect from "@/components/onboarding/EtradeBusinessSelect";
import { api } from "@/services/api";
import { fetchViewableFile } from "@/services/files.service";
import type { LicenseFile } from "@/services/companies.service";
@@ -63,6 +67,8 @@ export interface RoleLicenseProfile {
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
/** The eTrade business already attached to this profile, if any. */
etradeBusiness?: ETradeBusinessOption | null;
}
interface RoleLicenseStepProps {
@@ -73,6 +79,8 @@ interface RoleLicenseStepProps {
onChange: (value: Record<string, File[]>) => void;
/** "Business license is required" style error, keyed by profile id. */
errors?: Record<string, string>;
/** "Choose a business" error, keyed by profile id. */
businessErrors?: Record<string, string>;
}
/**
@@ -86,16 +94,43 @@ export default function RoleLicenseStep({
value,
onChange,
errors,
businessErrors,
}: RoleLicenseStepProps) {
const queryClient = useQueryClient();
const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files });
};
// Attaching saves immediately rather than riding along with the step's
// submit: the roles were created on the wizard's first step, so each already
// has a row to attach to, and persisting on pick means a refresh or a resumed
// draft keeps the choice.
const attach = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
// getInfo FIRST: the wizard reads its role list (and each role's attached
// business) from that query, so skipping it leaves the dropdown showing
// blank right after a successful pick.
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
},
});
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
Upload the business license for each of your operational profiles. You
can attach more than one document per profile.
For each operational profile, say which of your eTrade business licences
it operates as, and upload that licence. You can attach more than one
document per profile, and the same business can back more than one role.
</Text>
{profiles.map((profile) => {
@@ -104,7 +139,21 @@ export default function RoleLicenseStep({
const hasExisting = profile.existingFiles.length > 0;
return (
<>
<Fragment key={profile.id}>
<EtradeBusinessSelect
label={`Which business is your ${label} profile?`}
value={profile.etradeBusiness?.licenceNumber ?? null}
error={businessErrors?.[profile.id]}
// Only the row being saved locks; picking the importer's business
// must not freeze the exporter's dropdown next to it.
disabled={
attach.isPending && attach.variables?.profileId === profile.id
}
onChange={(licenceNumber) =>
attach.mutate({ profileId: profile.id, licenceNumber })
}
/>
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
@@ -142,7 +191,7 @@ export default function RoleLicenseStep({
setFiles(profile.id, files);
}}
/>
</>
</Fragment>
);
})}
</Stack>

View File

@@ -106,6 +106,9 @@ export const URL_CONSTANTS = {
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
ETRADE_BUSINESSES: "/api/companies/etrade-businesses",
PROFILE_ETRADE_BUSINESS: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/etrade-business`,
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,
@@ -226,6 +229,13 @@ export const URL_CONSTANTS = {
PUBLIC: "/api/support-content",
},
EMPTY_RETURN_REQUESTS: {
BASE: "/api/empty-return-requests",
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
BY_BOOKING: (bookingId: string) => `/api/empty-return-requests/by-booking/${bookingId}`,
SCHEDULE: (id: string) => `/api/empty-return-requests/${id}/schedule`,
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,

View File

@@ -244,9 +244,17 @@ const useAuth = () => {
const createProfile = async (
type: ProfileTypeValue,
licenseFiles: File[],
/**
* Which of the TIN's eTrade businesses the new role operates as. Required
* by the API for any company that has an eTrade record.
*/
licenceNumber?: string,
): Promise<Result<void>> => {
try {
const created = await api.companies.createCompanyProfile.call({ type });
const created = await api.companies.createCompanyProfile.call({
type,
licenceNumber,
});
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
}

View File

@@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +42,7 @@ import {
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
import { EmptyReturnRequestPanel } from "./components/EmptyReturnRequestPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
@@ -181,23 +183,70 @@ export function ReadonlyBookingView({
// backend flag counts only unsigned SELF_HAUL handovers, so no status
// heuristics are needed here.
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
// Delivery chosen on the contract only opens a last-mile request; EDR is
// committed to that leg once the request is approved (or a leg record
// exists). Until then the customer may still bring their own truck. Same
// rule as the API's `edrHaulsThisBooking`. Collection (the export leg) has
// no approval step, so the address alone decides it.
const hasLastMileChoice =
booking.tradeDirection !== "EXPORT" && !!booking.lastMileDeliveryAddress;
const lastMileRequests = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: hasLastMileChoice,
});
const mileSummary = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
enabled: hasLastMileChoice,
});
const lastMileCommitted =
hasLastMileChoice &&
((lastMileRequests.data ?? []).some((r) => r.status === "APPROVED") ||
!!mileSummary.data?.lastMile);
const lastMileCheckPending =
hasLastMileChoice && (lastMileRequests.isPending || mileSummary.isPending);
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
? !booking.lastMileDeliveryAddress
? !lastMileCommitted
: booking.tradeDirection === "EXPORT"
? !booking.firstMilePickupAddress
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
const canAssignCustomerTruck =
booking.paymentStatus === "PAID" &&
usesCustomerTruck &&
(booking.tradeDirection === "IMPORT"
? // Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
status === "ARRIVED"
: // Export / domestic self-haul: delivery trucks are assigned only before
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
["PAID", "TRUCK_ASSIGNED"].includes(status));
: !booking.firstMilePickupAddress && !lastMileCommitted;
// Why truck assignment is closed, or null when it is open. The card renders
// either way — a customer who opens Logistics and finds nothing there cannot
// tell a missing feature from a stage they have not reached yet.
const truckBlockedReason = (() => {
if (lastMileCheckPending) {
return "Checking whether EDR last-mile delivery has been approved for this booking…";
}
if (!usesCustomerTruck) {
return booking.tradeDirection !== "EXPORT" && lastMileCommitted
? "EDR last-mile delivery for this booking has been approved, so no customer truck is needed."
: "EDR is handling first-mile pickup for this booking, so no customer truck is needed.";
}
if (booking.paymentStatus !== "PAID") {
return "Truck assignment opens once payment for this booking is confirmed.";
}
if (booking.tradeDirection === "IMPORT") {
// Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
return status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED"
? null
: "Pickup trucks can be assigned once the train arrives at the destination.";
}
// Export / domestic self-haul: delivery trucks are assigned only before the
// cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
return ["PAID", "TRUCK_ASSIGNED"].includes(status)
? null
: "The cargo is already loaded onto the train — truck assignment is closed.";
})();
// The customer asked for EDR delivery and can still self-haul instead — say
// so, because assigning a truck here makes that pending request unapprovable.
const truckNotice =
!truckBlockedReason && hasLastMileChoice && !lastMileCommitted
? "You requested EDR last-mile delivery for this booking, and it has not been approved yet. Assigning your own truck here replaces that request — it can no longer be approved once a truck is on the booking."
: null;
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
@@ -415,6 +464,7 @@ export function ReadonlyBookingView({
showCountdown={showCountdown}
/>
<AdditionalChargesPanel bookingId={booking.id} />
<EmptyReturnRequestPanel bookingId={booking.id} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
@@ -462,14 +512,16 @@ export function ReadonlyBookingView({
<BodyGrid
left={
<>
<WarehouseLocationCard bookingId={booking.id} />
{/* Truck assignment leads; where the cargo currently sits is
supporting detail beneath it. */}
<CustomerTruckAssignmentCard
booking={booking}
blockedReason={truckBlockedReason}
notice={truckNotice}
onAssigned={onBookingUpdated ?? (() => {})}
/>
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehouseLocationCard bookingId={booking.id} />
<MileSummaryCard booking={booking} />
</>

View File

@@ -80,6 +80,8 @@ export type BookingDetail = Freight.IBooking & {
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
/** The schedule's own voyage (sailing) number for this departure. */
voyageNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;

View File

@@ -1,131 +1,237 @@
import { useState } from "react";
import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core";
import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
Button,
FileButton,
Group,
List,
Modal,
Stack,
Table,
Text,
} from "@mantine/core";
import { AlertCircle, AlertTriangle, CheckCircle, Download, Upload } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { client } from "@/utils/api";
import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
import {
downloadTruckAssignmentTemplate,
parseTruckAssignmentFile,
truckTemplateContext,
type ParsedTruckFile,
} from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
interface BulkTruckUploadResponse {
success: number;
failed: number;
errors: Array<{ index: number; row: number; truck: string; reason: string }>;
}
interface BulkTruckUploadModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
booking: BookingDetail;
trucks: Freight.ICustomerTruck[];
onSuccess?: () => void;
}
/** Long error lists are unreadable in a modal — show the first few and count the rest. */
const MAX_LISTED_ERRORS = 8;
export function BulkTruckUploadModal({
opened,
onClose,
bookingId,
booking,
trucks,
onSuccess,
}: BulkTruckUploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<
Array<{
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers?: string[];
}>
>([]);
const [parseError, setParseError] = useState<string | null>(null);
const queryClient = useQueryClient();
// `resetRef` lets the customer re-pick a corrected file with the same name —
// without it the input's onChange never fires the second time.
const resetFile = useRef<() => void>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [parsed, setParsed] = useState<ParsedTruckFile>({ rows: [], errors: [], rowNumbers: [] });
const [result, setResult] = useState<BulkTruckUploadResponse | null>(null);
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const reset = () => {
resetFile.current?.();
setFileName(null);
setParsed({ rows: [], errors: [], rowNumbers: [] });
setResult(null);
};
const uploadMutation = useMutation({
mutationFn: async () => {
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;
const { data } = await client.post<BulkTruckUploadResponse | { data: BulkTruckUploadResponse }>(
URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(booking.id),
{ trucks: parsed.rows },
);
return ("data" in data ? data.data : data) as BulkTruckUploadResponse;
},
onSuccess: () => {
onSuccess: (response) => {
void queryClient.invalidateQueries({ queryKey: ["customer-trucks", booking.id] });
onSuccess?.();
setFile(null);
setParsed([]);
onClose();
// Only a clean run closes. A partial failure has to be shown, or the
// customer walks away believing all their trucks were created.
if (response.failed === 0) {
reset();
onClose();
return;
}
setResult(response);
},
});
const handleFileSelect = async (selectedFile: File | null) => {
if (!selectedFile) {
setFile(null);
setParsed([]);
setParseError(null);
const handleFile = async (selected: File | null) => {
setResult(null);
if (!selected) {
reset();
return;
}
setFileName(selected.name);
try {
setParseError(null);
const trucks = await parseTruckAssignmentFile(selectedFile);
setFile(selectedFile);
setParsed(trucks);
} catch (err: any) {
setParseError(err.message || "Failed to parse Excel file");
setFile(null);
setParsed([]);
setParsed(await parseTruckAssignmentFile(selected, ctx));
} catch (err) {
setParsed({
rows: [],
errors: [err instanceof Error ? err.message : "Could not read the file."],
rowNumbers: [],
});
}
};
const handleDownloadTemplate = () => {
generateTruckAssignmentTemplate("truck-assignments.xlsx");
};
const { rows, errors, rowNumbers } = parsed;
const listedErrors = errors.slice(0, MAX_LISTED_ERRORS);
const hiddenErrors = errors.length - listedErrors.length;
/** Map a server error back to the spreadsheet row the customer actually sees. */
const excelRowFor = (index: number, fallback: number) => rowNumbers[index] ?? fallback;
const columnLabel =
ctx.shape === "CONTAINER"
? "Containers"
: ctx.shape === "PER_ITEM"
? `Quantity (${ctx.itemNoun})`
: "Planned tons";
return (
<Modal
opened={opened}
onClose={onClose}
title="Bulk Upload Truck Assignments"
onClose={() => {
reset();
onClose();
}}
title="Bulk upload truck assignments"
size="lg"
centered
>
<Stack gap="lg">
<Alert icon={<AlertCircle size={16} />} color="blue">
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
Download the template for this booking, fill in one row per truck, then upload it.
{ctx.shape === "CONTAINER"
? " It lists this booking's containers and their sizes."
: ctx.remainingTons != null
? ` ${ctx.remainingTons} t are still to be hauled.`
: ""}
</Alert>
<Group>
<Button
leftSection={<Download size={16} />}
variant="light"
onClick={handleDownloadTemplate}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
Download template
</Button>
<FileButton resetRef={resetFile} accept=".xlsx,.xls" onChange={handleFile}>
{(props) => (
<Button {...props} variant="default" leftSection={<Upload size={16} />}>
{fileName ?? "Choose Excel file"}
</Button>
)}
</FileButton>
</Group>
<FileInput
label="Select Excel File"
placeholder="Choose .xlsx file"
accept=".xlsx,.xls"
value={file}
onChange={handleFileSelect}
leftSection={<Upload size={14} />}
/>
{parseError && (
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
{parseError}
{errors.length > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="red"
title="Import failed — fix the file and upload it again"
>
<List size="sm" spacing={4}>
{listedErrors.map((message) => (
<List.Item key={message}>{message}</List.Item>
))}
</List>
{hiddenErrors > 0 && (
<Text size="sm" mt={6}>
and {hiddenErrors} more.
</Text>
)}
</Alert>
)}
{parsed.length > 0 && (
{result && result.failed > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="orange"
title={`${result.success} truck(s) added, ${result.failed} rejected`}
>
<Table verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Row</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Reason</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{result.errors.map((e) => (
<Table.Tr key={`${e.index}-${e.truck}`}>
<Table.Td>{excelRowFor(e.index, e.row)}</Table.Td>
<Table.Td>{e.truck}</Table.Td>
<Table.Td>
<Text size="sm">{e.reason}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Alert>
)}
{rows.length > 0 && (
<>
<div>
<Text fw={600} mb="xs">
Preview ({parsed.length} trucks)
Preview ({rows.length} truck{rows.length !== 1 ? "s" : ""})
</Text>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate Number</Table.Th>
<Table.Th>Driver Name</Table.Th>
<Table.Th>Truck Type</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>Row</Table.Th>
<Table.Th>Plate number</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>{columnLabel}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{parsed.map((truck, idx) => (
<Table.Tr key={idx}>
{rows.map((truck, idx) => (
<Table.Tr key={`${truck.truckPlateNumber}-${idx}`}>
<Table.Td>
<Text size="sm" c="dimmed">
{rowNumbers[idx]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{truck.truckPlateNumber}</Text>
</Table.Td>
@@ -136,18 +242,21 @@ export function BulkTruckUploadModal({
<Text size="sm">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{truck.containerNumbers?.length ? (
{ctx.shape === "CONTAINER" ? (
<Group gap="xs">
{truck.containerNumbers.map((c) => (
{(truck.containerNumbers ?? []).map((c) => (
<Badge key={c} size="sm">
{c}
</Badge>
))}
</Group>
) : (
<Text size="sm" c="dimmed">
) : ctx.shape === "PER_ITEM" ? (
<Text size="sm">
{truck.plannedQuantity}
{truck.plannedTons ? ` · ${truck.plannedTons} t` : ""}
</Text>
) : (
<Text size="sm">{truck.plannedTons} t</Text>
)}
</Table.Td>
</Table.Tr>
@@ -158,14 +267,14 @@ export function BulkTruckUploadModal({
<Group justify="space-between">
<Text size="sm" c="dimmed">
Ready to upload {parsed.length} truck(s)
Ready to upload {rows.length} truck{rows.length !== 1 ? "s" : ""}
</Text>
<Button
loading={uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
leftSection={<CheckCircle size={16} />}
>
Upload Trucks
Upload trucks
</Button>
</Group>
</>

View File

@@ -16,9 +16,20 @@ import {
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react";
import { useState } from "react";
import { CUSTOMER_TRUCK_TYPES, type Freight } from "@edr/types";
import {
AlertTriangle,
CheckCircle2,
Clock,
Download,
Lock,
Pencil,
Plus,
Trash2,
Truck,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
@@ -26,9 +37,14 @@ import { customerTrucksService } from "@/services/customer-trucks.service";
import { CardTitle, SectionCard } from "./layout";
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
import {
downloadTruckAssignmentTemplate,
isTwentyFoot,
truckTemplateContext,
} from "@/utils/truck-assignment-template";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const TRUCK_TYPES = [...CUSTOMER_TRUCK_TYPES];
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
// copies (Port Operations, Gate Security & Carrier) are always printed.
@@ -64,9 +80,19 @@ const errorMessage = (error: unknown, fallback: string) => {
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
blockedReason,
notice,
}: {
booking: Freight.IBooking;
booking: BookingDetail;
onAssigned: () => void;
/**
* Why assignment is closed right now, if it is. The card still renders — and
* still lists any trucks already assigned — with this shown in place of the
* form, rather than the whole card vanishing from the Logistics tab.
*/
blockedReason?: string | null;
/** Something the customer should know before assigning — shown above the form when it is open. */
notice?: string | null;
}) {
const queryClient = useQueryClient();
const trucksKey = ["customer-trucks", booking.id];
@@ -86,6 +112,12 @@ export function CustomerTruckAssignmentCard({
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
// What this booking hauls: containers, loose tonnage (PER_TON), or counted
// items (PER_ITEM — machinery, RoRo vehicles). Drives the form, the Excel
// template and its parser from one place.
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const isPerItem = ctx.shape === "PER_ITEM";
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
@@ -94,9 +126,20 @@ export function CustomerTruckAssignmentCard({
const editingOwn = new Set(
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
);
const availableContainers = (booking.containerNumbers ?? []).filter(
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
const unassignedContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
// A 40ft fills the truck. Once one is picked, or two 20ft are, nothing more
// may be added — the API rejects it either way, so don't offer the choice.
const pickedHas40ft = containers.some((n) => !isTwentyFoot(containerSizes.get(n) ?? ""));
const availableContainers = unassignedContainers.filter((n) => {
if (containers.includes(n)) return true;
if (pickedHas40ft) return false;
// Something is already picked and it's 20ft — only another 20ft may join it.
if (containers.length > 0) return isTwentyFoot(containerSizes.get(n) ?? "");
return true;
});
// Containers on the booking not yet assigned to any truck (independent of edit).
const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n),
@@ -115,16 +158,12 @@ export function CustomerTruckAssignmentCard({
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setPlannedTons(t.plannedTons != null ? Number(t.plannedTons) : "");
setPlannedQty(t.plannedQuantity != null ? Number(t.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -187,7 +226,13 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
// Counted cargo (machinery, RoRo vehicles) is committed by item count —
// tonnage is often unknown until the weighbridge, so it stays optional.
if (isBulk && isPerItem && plannedQty === "") {
setError(`Enter how many ${ctx.itemNoun} this truck will carry.`);
return;
}
if (isBulk && !isPerItem && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
@@ -204,25 +249,27 @@ export function CustomerTruckAssignmentCard({
<CardTitle>External Truck Assignment</CardTitle>
</Group>
<Group gap={12}>
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => generateTruckAssignmentTemplate("truck-assignments.xlsx")}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
{pendingAssignmentCount > 0 && (
{!blockedReason && (
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
)}
{!blockedReason && pendingAssignmentCount > 0 && (
<Text size="sm" fw={600} c="#b45309">
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
</Text>
@@ -306,6 +353,26 @@ export function CustomerTruckAssignmentCard({
</Alert>
)}
{/* Assignment is closed for now — say why, and keep the trucks above
visible rather than hiding the whole card. */}
{blockedReason ? (
<Alert color="gray" variant="light" icon={<Lock size={16} />}>
{blockedReason}
</Alert>
) : (
<>
{notice && (
<Alert color="yellow" variant="light" icon={<AlertTriangle size={16} />}>
{notice}
</Alert>
)}
{!isBulk && (
<Alert color="blue" variant="light">
Truck capacity: assign either <b>1 x 40ft container</b> or up to{' '}
<b>2 x 20ft containers</b>. A 40ft container must travel alone.
</Alert>
)}
{/* Add-truck form. Container bookings assign 12 containers per truck;
bulk bookings just register the truck (no container picker). */}
{isBulk || availableContainers.length > 0 ? (
@@ -337,34 +404,39 @@ export function CustomerTruckAssignmentCard({
description="20ft: up to 2 per truck · 40ft: 1 per truck"
required
placeholder="Select container numbers"
data={availableContainers}
data={availableContainers.map((n) => {
const size = containerSizes.get(n);
return { value: n, label: size ? `${n} · ${size}` : n };
})}
value={containers}
onChange={setContainers}
maxValues={2}
maxValues={pickedHas40ft ? 1 : 2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
label={isPerItem ? "Tonnes to load (optional)" : "Tonnes to load"}
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
.reduce(
(s, t) =>
s +
(Number(
(t as Freight.ICustomerTruck & { netWeightTons?: number | string | null })
.netWeightTons ?? t.plannedTons,
) || 0),
0,
);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
required={!isPerItem}
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
@@ -372,9 +444,17 @@ export function CustomerTruckAssignmentCard({
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
// Counted cargo commits by piece count; loose bulk records it
// only as a note alongside the tonnage that actually bills.
label={isPerItem ? `Number of ${ctx.itemNoun}` : "Items quantity (pcs)"}
description={
isPerItem
? `How many ${ctx.itemNoun} ride this truck`
: "Optional piece count on this truck"
}
required={isPerItem}
min={0}
allowDecimal={false}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
@@ -403,6 +483,8 @@ export function CustomerTruckAssignmentCard({
</Text>
)
)}
</>
)}
{trucks.length > 0 && (
<Stack gap="xs">
@@ -457,7 +539,8 @@ export function CustomerTruckAssignmentCard({
<BulkTruckUploadModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
bookingId={booking.id}
booking={booking}
trucks={trucks}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: trucksKey });
onAssigned();

View File

@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
/**
* A `responseType: "blob"` request delivers its JSON error body as a Blob, so
* reading `error.message` gives "Request failed with status code 400" instead
* of the reason. Read the blob back before falling back.
*/
async function downloadErrorMessage(error: unknown, fallback: string): Promise<string> {
const data = (error as { response?: { data?: unknown } })?.response?.data;
if (data instanceof Blob) {
try {
const parsed = JSON.parse(await data.text()) as { message?: unknown };
if (parsed?.message) return String(parsed.message);
} catch {
/* not JSON — fall through */
}
}
return error instanceof Error ? error.message : fallback;
}
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
// One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false);
// The carriage acceptance sheet is its own document, not warehouse paperwork:
// direct truck-to-train cargo never sees a warehouse, and this sheet IS its
// handover record. Hiding it inside the warehouse bundle made it unfindable.
const [casBusy, setCasBusy] = useState(false);
const downloadCarriageAcceptance = async () => {
setCasBusy(true);
const ref = booking.reference ?? booking.id;
try {
const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
} catch (error) {
toast.error(
await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
);
} finally {
setCasBusy(false);
}
};
const downloadWarehouseDocuments = async () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
<SectionCard>
<CardTitle>Carriage acceptance sheet</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
The record of the cargo EDR has accepted for carriage, listing each wagon and the
containers on it, and marking which have been loaded.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
variant="light"
loading={casBusy}
onClick={downloadCarriageAcceptance}
>
Download sheet
</Button>
</SectionCard>
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>

View File

@@ -0,0 +1,446 @@
import { useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Container as ContainerIcon, CreditCard } from "lucide-react";
import toast from "react-hot-toast";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import {
emptyReturnRequestsService,
type EmptyReturnRequest,
type EmptyReturnRequestStatus,
} from "@/services/empty-return-requests.service";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
SUBMITTED: { label: "Awaiting EDR review", color: "#B07D14" },
APPROVED: { label: "Awaiting payment", color: "#B07D14" },
REJECTED: { label: "Rejected", color: "red" },
PAID: { label: "Paid — choose your date", color: "#1F6FEB" },
SCHEDULED: { label: "Scheduled", color: "#0A6F4D" },
COMPLETED: { label: "Returned", color: "#0A6F4D" },
CANCELLED: { label: "Cancelled", color: "#9AA8B5" },
};
const money = (amount: number | null, currency: string | null) =>
amount == null
? "—"
: `${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? "ETB"}`;
const errorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message ?? fallback;
};
/**
* Returning empties on a booking that did NOT buy the return service up front.
* The customer picks the containers off this booking; EDR prices and approves
* it; the customer pays here and then books the date and the truck that brings
* them in.
*/
export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const eligibilityQuery = useQuery({
queryKey: ["empty-return-eligibility", bookingId],
queryFn: () => emptyReturnRequestsService.eligibility(bookingId),
});
const requestsQuery = useQuery({
queryKey: ["empty-return-requests", bookingId],
queryFn: () => emptyReturnRequestsService.listForBooking(bookingId),
});
const requests = requestsQuery.data ?? [];
const live = requests.filter((r) => r.status !== "REJECTED" && r.status !== "CANCELLED");
const eligibility = eligibilityQuery.data;
const refresh = () => {
qc.invalidateQueries({ queryKey: ["empty-return-requests", bookingId] });
qc.invalidateQueries({ queryKey: ["empty-return-eligibility", bookingId] });
};
// Nothing to offer and nothing to show — stay out of the way entirely.
if (!eligibility?.eligible && requests.length === 0) return null;
return (
<SectionCard p={22}>
<CardTitle>Empty container return</CardTitle>
<Stack gap={14} mt={12}>
{live.map((request) => (
<RequestRow key={request.id} request={request} onChanged={refresh} />
))}
{requests
.filter((r) => r.status === "REJECTED")
.map((request) => (
<Box key={request.id} p={12} style={{ borderRadius: 10, border: "1px solid #FDE2E1" }}>
<Group justify="space-between">
<Text fz="13px" fw={600} c="#10202F">
{request.containerCount} container{request.containerCount === 1 ? "" : "s"}
</Text>
<Badge radius="sm" variant="light" color="red">
Rejected
</Badge>
</Group>
{request.rejectionReason && (
<Text fz="12px" c="#9AA8B5" mt={4}>
{request.rejectionReason}
</Text>
)}
</Box>
))}
{eligibility?.eligible ? (
<NewRequestForm
bookingId={bookingId}
availableNumbers={eligibility.availableContainerNumbers}
unitAmount={eligibility.quote.unitAmount}
currency={eligibility.quote.currency}
onCreated={refresh}
/>
) : (
eligibility?.reason &&
live.length === 0 && (
<Text fz="12px" c="#9AA8B5">
{eligibility.reason}
</Text>
)
)}
</Stack>
</SectionCard>
);
}
/** One live request: what it costs, what it is waiting on, and the next step. */
function RequestRow({
request,
onChanged,
}: {
request: EmptyReturnRequest;
onChanged: () => void;
}) {
const meta = STATUS_META[request.status];
return (
<Box p={14} style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={6}>
<ContainerIcon size={13} color="#9AA8B5" />
<Text fz="13.5px" fw={700} c="#10202F">
{request.containerCount} empty container{request.containerCount === 1 ? "" : "s"}
</Text>
</Group>
<Text fz="12px" c="#9AA8B5" mt={2}>
{request.containerNumbers.join(", ")}
</Text>
{request.quotedTotalAmount != null && (
<Text fz="12px" c="#9AA8B5" mt={2}>
{money(request.quotedTotalAmount, request.currency)}
{request.quotedUnitAmount != null &&
` · ${money(request.quotedUnitAmount, request.currency)} per container`}
</Text>
)}
{request.requestedReturnDate && (
<Text fz="12px" c="#9AA8B5" mt={2}>
Returning {request.requestedReturnDate} · truck {request.truckPlateNumber}
</Text>
)}
</Box>
<Badge
radius="sm"
variant="light"
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
>
{meta.label}
</Badge>
</Group>
{request.status === "APPROVED" && request.invoiceId && (
<PayButton
invoiceId={request.invoiceId}
amount={request.quotedTotalAmount ?? 0}
currency={request.currency ?? "ETB"}
/>
)}
{request.status === "PAID" && <ScheduleForm request={request} onScheduled={onChanged} />}
</Box>
);
}
function PayButton({
invoiceId,
amount,
currency,
}: {
invoiceId: string;
amount: number;
currency: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const flow = useInvoicePayment();
const close = () => {
if (!flow.processing) {
setModalOpen(false);
flow.reset();
}
};
return (
<ModalSafeWrapper>
<Button
mt={10}
size="xs"
radius="md"
fw={700}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={(event) => {
event.stopPropagation();
setModalOpen(true);
}}
>
Pay now
</Button>
<PaymentMethodModal
opened={modalOpen}
onClose={close}
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
currency={currency}
processing={flow.processing}
error={flow.error}
otp={flow.otp}
bill={flow.bill}
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
/>
</ModalSafeWrapper>
);
}
/** After payment: when the empties come back, and on whose truck. */
function ScheduleForm({
request,
onScheduled,
}: {
request: EmptyReturnRequest;
onScheduled: () => void;
}) {
const [returnDate, setReturnDate] = useState("");
const [plate, setPlate] = useState("");
const [driver, setDriver] = useState("");
const [truckType, setTruckType] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
emptyReturnRequestsService.schedule(request.id, {
returnDate,
truckPlateNumber: plate.trim(),
truckDriverName: driver.trim(),
truckType: truckType ?? undefined,
}),
onSuccess: () => {
toast.success("Return date and truck saved");
onScheduled();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not save the return details"));
},
});
const ready = Boolean(returnDate && plate.trim() && driver.trim());
return (
<Stack gap={10} mt={12}>
<Text fz="12px" fw={600} c="#10202F">
Tell us when the containers are coming back
</Text>
<TextInput
size="xs"
type="date"
label="Return date"
value={returnDate}
onChange={(event) => setReturnDate(event.currentTarget.value)}
required
/>
<TextInput
size="xs"
label="Truck plate"
placeholder="3-A12345"
value={plate}
onChange={(event) => setPlate(event.currentTarget.value.toUpperCase())}
required
/>
<TextInput
size="xs"
label="Driver name"
value={driver}
onChange={(event) => setDriver(event.currentTarget.value)}
required
/>
<Select
size="xs"
label="Truck type"
placeholder="Select"
data={TRUCK_TYPES}
value={truckType}
onChange={setTruckType}
clearable
/>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={!ready}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Confirm return details
</Button>
</Stack>
);
}
/**
* The booking's own containers, ticked. Only a container that came in on this
* booking can go back on it, so the customer picks from that list rather than
* typing numbers, and the count follows the ticks.
*/
function NewRequestForm({
bookingId,
availableNumbers,
unitAmount,
currency,
onCreated,
}: {
bookingId: string;
availableNumbers: string[];
unitAmount: number | null;
currency: string;
onCreated: () => void;
}) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () => emptyReturnRequestsService.create(bookingId, selected),
onSuccess: () => {
toast.success("Empty return requested — EDR will review and price it");
setOpen(false);
setSelected([]);
onCreated();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not submit the request"));
},
});
if (!open) {
return (
<Stack gap={6}>
<Button
size="xs"
radius="md"
fw={700}
variant="light"
color="edr-green"
onClick={() => setOpen(true)}
>
Request empty return
</Button>
{unitAmount != null && (
<Text fz="11.5px" c="#9AA8B5">
{money(unitAmount, currency)} per container, payable after EDR approves.
</Text>
)}
</Stack>
);
}
return (
<Stack gap={10}>
<Group justify="space-between" align="center">
<Text fz="12px" fw={600} c="#10202F">
Select the containers you are returning
</Text>
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
onClick={() =>
setSelected(selected.length === availableNumbers.length ? [] : [...availableNumbers])
}
>
{selected.length === availableNumbers.length ? "Clear all" : "Select all"}
</Button>
</Group>
<Stack gap={6}>
{availableNumbers.map((number) => (
<Checkbox
key={number}
size="xs"
label={number}
checked={selected.includes(number)}
onChange={(event) =>
setSelected((current) =>
event.currentTarget.checked
? [...current, number]
: current.filter((value) => value !== number),
)
}
/>
))}
</Stack>
{unitAmount != null && selected.length > 0 && (
<Alert color="gray" p={10}>
<Text fz="12px">
Estimated {money(unitAmount * selected.length, currency)} for {selected.length} container
{selected.length === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
</Text>
</Alert>
)}
<Group gap={8}>
<Button size="xs" variant="default" radius="md" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={selected.length === 0}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Submit request{selected.length > 0 ? ` (${selected.length})` : ""}
</Button>
</Group>
</Stack>
);
}

View File

@@ -106,12 +106,19 @@ export function ScheduleCard({
value: <StatusPill status={booking.status as string} />,
};
// The schedule's own voyage (sailing) number — shown once the booking has an
// assigned train that carries one.
const voyageRows: Row[] = schedule?.voyageNumber
? [{ label: "Voyage number", value: schedule.voyageNumber }]
: [];
const rows: Row[] = consignment
? [
{ label: "Consignment ID", value: booking.reference },
{ label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
...voyageRows,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
]
: [
@@ -120,6 +127,7 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
...voyageRows,
];
return (

View File

@@ -139,8 +139,17 @@ export function WagonCancellationCard({
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
const rows = data?.items ?? [];
const ownRows = rows.filter((r) => r.bookingId === booking.id);
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
// A cancellation owes its fee whenever a customer-fault fee is still
// unsettled. FEE_PENDING is the customer-requested flow (cut applies at
// payment); an AT-LOADING cut applies immediately and jumps straight to
// CREDIT_AVAILABLE with its invoice left open — so status alone would hide
// the fee and offer a "no further payment needed" rebook on money still owed.
const owesFee = (r: (typeof ownRows)[number]) =>
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
const openRow = ownRows.find((r) => r.status === "FEE_PENDING" || owesFee(r));
const creditRow = ownRows.find(
(r) => r.status === "CREDIT_AVAILABLE" && !owesFee(r),
);
const feePay = useFeeInvoicePayment(booking.id);
@@ -237,7 +246,14 @@ export function WagonCancellationCard({
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
});
if (!eligible) return null;
// Showing the card is NOT the same as allowing a new cut. A partial cancel at
// loading leaves the kept wagons to ride, so the booking moves on to
// IN_TRANSIT/ARRIVED/COMPLETED while its cancellation still owes a fee and
// holds a rebookable credit. Gating on PAID/CANCELLED hid exactly that case —
// the customer saw neither the cancelled wagons nor the fee they owe. Any
// booking that HAS cancellation rows keeps the card, whatever its status;
// `canRequest` still limits NEW cuts to a live PAID booking.
if (!eligible && !ownRows.length) return null;
if (!canRequest && !ownRows.length) return null;
return (
@@ -265,8 +281,10 @@ export function WagonCancellationCard({
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit. The request cannot be withdrawn from here
if it was a mistake, contact EDR staff.
the rebooking credit the {Number(openRow.wagonsCancelled)} cancelled
wagon(s) can then be rebooked on a coming train day. The request
cannot be withdrawn from here if it was a mistake, contact EDR
staff.
</Alert>
<Group gap={8}>
<Button
@@ -293,7 +311,7 @@ export function WagonCancellationCard({
{isCustoms || oddFt20Credit ? (
<Text fz={13} c="#475569">
{isCustoms
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date."
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
</Text>
) : (

View File

@@ -459,7 +459,15 @@ export default function BookingsListPage() {
const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) {
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
// A customer-fault cut invoices a fee. An at-loading cut applies at once
// and opens the credit with that invoice still OPEN, so CREDIT_AVAILABLE
// alone never means the fee was settled — offering "Rebook" here would
// let the customer redeem the wagons without ever paying. Those rows fall
// through to the row's Pay button instead (the fee is on my-payables).
const owesFee =
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
if (r.status === "CREDIT_AVAILABLE" && !owesFee && !m.has(r.bookingId))
m.set(r.bookingId, r);
}
return m;
}, [myCancellations]);

View File

@@ -38,6 +38,9 @@ import {
useParams,
} from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import EtradeBusinessSelect, {
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import {
CONTAINER_SIZES,
CONTRACT_STEPS,
@@ -417,6 +420,11 @@ export default function NewContractPage({
[profileStatusByType, profileTypes],
);
// Empty for a co-operative or investor-licence company — eTrade holds no
// record for it, so there is no business to attach and none is asked for.
const { data: etradeBusinesses } = useEtradeBusinesses();
const createBusinessRequired = (etradeBusinesses?.length ?? 0) > 0;
// Create-profile modal state (license upload → createProfile).
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
null,
@@ -424,6 +432,9 @@ export default function NewContractPage({
const [pendingOperation, setPendingOperation] =
useState<OperationType | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
// Which eTrade business the new role operates as — a TIN holds several
// licences and the role corresponds to one of them.
const [createLicence, setCreateLicence] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
// After a license is uploaded the new profile comes back "pending", so the
// create-profile modal switches to an "awaiting approval" success state.
@@ -437,11 +448,13 @@ export default function NewContractPage({
mutationFn: async ({
type,
files,
licenceNumber,
}: {
type: ProfileTypeValue;
files: File[];
licenceNumber?: string;
}) => {
const res = await auth.createProfile(type, files);
const res = await auth.createProfile(type, files, licenceNumber);
if (!res.success) {
throw new Error(res.error?.message ?? "Failed to create profile");
}
@@ -507,7 +520,17 @@ export default function NewContractPage({
setCreateError("Please upload at least one business license file.");
return;
}
createProfileMutation.mutate({ type: createTarget, files: licenseFiles });
// Only companies eTrade actually knows have a licence list; a co-operative
// has none, and the API does not ask them for one.
if (createBusinessRequired && !createLicence) {
setCreateError("Please choose which eTrade business this profile is.");
return;
}
createProfileMutation.mutate({
type: createTarget,
files: licenseFiles,
licenceNumber: createLicence ?? undefined,
});
};
const handleCreateProfileCancel = () => {
@@ -1235,6 +1258,13 @@ export default function NewContractPage({
Add your business license to create one. It goes to staff for
approval before you can use it.
</Text>
{createBusinessRequired && (
<EtradeBusinessSelect
value={createLicence}
onChange={setCreateLicence}
disabled={createProfileMutation.isPending}
/>
)}
<FileInput
label="Business license"
multiple

View File

@@ -595,7 +595,7 @@ function NewShipmentBookingForm({
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
sealNumber: u.sealNumber.trim(),
vgmTons: Number(u.vgmTons),
// Per-container handling — the server rolls these up into the
// line counts and bills each surcharge on the ticked containers.
@@ -660,6 +660,21 @@ function NewShipmentBookingForm({
// summary alert next to the submit button so the click never looks inert.
const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid;
// Export completion must lock onto a train. The button stays enabled (a
// disabled button with no explanation looks broken) — instead, once the
// customer tries to review, name the missing pick here in the always-visible
// footer, because the train picker itself is usually scrolled off-screen.
const requiresTrainPick =
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
const watchedScheduledDate = form.watch("scheduledDate");
const watchedTrainId = form.watch("trainScheduleId");
const validationSummaryText = !requiresTrainPick
? "Fix the highlighted fields to review the price."
: !watchedScheduledDate?.trim()
? "Select a shipment day and a train to review the price."
: !watchedTrainId?.trim()
? "Select a train for your shipment day to review the price."
: "Fix the highlighted fields to review the price.";
const handleConfirm = () => {
if (!pendingValues) return;
@@ -838,7 +853,7 @@ function NewShipmentBookingForm({
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
{validationSummaryText}
</Text>
</>
)}
@@ -2309,7 +2324,7 @@ function ContainerLineEditor({
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
Seal number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
@@ -2355,10 +2370,11 @@ function ContainerLineEditor({
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<TextInput
{...field}
placeholder="Optional"
placeholder="e.g. SL0123456"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseContainerExcel } from "./container-excel";
// Seals became mandatory at booking time — a spreadsheet row without one must
// reject the whole file, the same way a missing VGM already does.
const OPTS = {
allowedSizes: ["20ft", "40ft"],
includeHazardous: false,
includeReefer: false,
};
function sheetFile(rows: string[][]): File {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.aoa_to_sheet([
["Container Size", "Container Number", "Seal Number", "VGM (Tons)"],
...rows,
]),
"Containers",
);
const buffer = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
return new File([buffer], "containers.xlsx");
}
describe("parseContainerExcel", () => {
it("accepts a row carrying a seal", async () => {
const result = await parseContainerExcel(
sheetFile([["40ft", "MSCU1234567", "SL-0099231", "24.5"]]),
OPTS,
);
expect(result.errors).toEqual([]);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].sealNumber).toBe("SL-0099231");
});
it("rejects the file when a row has no seal", async () => {
const result = await parseContainerExcel(
sheetFile([
["40ft", "MSCU1234567", "SL-0099231", "24.5"],
["20ft", "MSCU7654321", "", "12"],
]),
OPTS,
);
expect(result.rows).toEqual([]);
expect(result.errors).toContain("Row 3: seal number is required.");
});
});

View File

@@ -151,6 +151,11 @@ export async function parseContainerExcel(
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const sealNumber = cell("sealNumber");
if (!sealNumber) {
errors.push(`Row ${rowNo}: seal number is required.`);
}
const vgmRaw = cell("vgmTons");
const vgm = Number(vgmRaw);
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
rows.push({
containerSize: size ?? "",
containerNumber,
sealNumber: cell("sealNumber"),
sealNumber,
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),

View File

@@ -61,7 +61,12 @@ const containerUnitSchema = z.object({
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"Enter a valid ISO container number (e.g. ABCD1234567).",
),
sealNumber: z.string().default(""),
// Every physical container is sealed before it ships; the yard checks the
// seal against the booking, so it is required alongside number and VGM.
sealNumber: z
.string()
.default("")
.refine((v) => v.trim().length > 0, "Seal number is required."),
vgmTons: z
.string()
.refine((v) => v.trim().length > 0, "VGM is required.")

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema } from "./schema";
const schema = createShipmentFormSchema({
isContainer: true,
isHazardous: false,
isReefer: false,
withReturnService: false,
requiresDate: true,
});
const unit = (overrides: Partial<{ sealNumber: string }> = {}) => ({
containerNumber: "MSCU1234567",
sealNumber: "SL0123456",
vgmTons: "24.5",
isHazardous: false,
isReefer: false,
isReturn: false,
...overrides,
});
const values = (sealNumber: string) => ({
contractRouteId: "route-1",
scheduledDate: "2026-09-10",
paymentCurrency: "USD" as const,
containers: [
{
containerSize: "40ft" as const,
quantity: "1",
units: [unit({ sealNumber })],
},
],
});
const sealIssue = (v: ReturnType<typeof values>) => {
const result = schema.safeParse(v);
return result.success
? undefined
: result.error.issues.find((i) => i.path.at(-1) === "sealNumber");
};
describe("container unit seal number", () => {
it("rejects a missing seal number", () => {
expect(sealIssue(values(""))?.message).toBe("Seal number is required.");
});
it("rejects a whitespace-only seal number", () => {
expect(sealIssue(values(" "))?.message).toBe("Seal number is required.");
});
it("accepts a filled seal number", () => {
expect(sealIssue(values("SL0123456"))).toBeUndefined();
});
});

View File

@@ -6,10 +6,15 @@ import {
Card,
Group,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import EtradeBusinessSelect, {
businessLabel,
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import type { CompanyProfileResponse } from "@/services/companies.service";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
@@ -63,6 +68,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
// Which eTrade business each newly-selected role will operate as. A TIN holds
// many licences split by activity, so this is picked per role, not per
// company — and the same business may back several roles.
const [licenceByType, setLicenceByType] = useState<Record<string, string>>({});
// Empty for a co-operative or investor-licence company: eTrade holds no
// record for it, so there is nothing to attach and nothing to require.
const { data: businesses } = useEtradeBusinesses();
const businessRequired = (businesses?.length ?? 0) > 0;
const toggle = (type: string) => {
if (profileByType.has(type)) return; // add-only: existing roles are locked
setSelected((prev) => {
@@ -71,13 +86,40 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
else next.add(type);
return next;
});
// Deselecting drops the licence with it, so re-picking the role does not
// silently reuse a choice the user backed out of.
setLicenceByType((prev) => {
const next = { ...prev };
delete next[type];
return next;
});
};
// Attach (or change) the business on a role that already exists.
const attachMutation = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const mutation = useMutation({
mutationFn: (types: string[]) =>
api.companies.addCompanyProfiles.call({ types }),
api.companies.addCompanyProfiles.call({
profiles: types.map((type) => ({
type,
licenceNumber: licenceByType[type],
})),
}),
onSuccess: () => {
setSelected(new Set());
setLicenceByType({});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
@@ -103,8 +145,14 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
},
});
// Every selected role needs its business named first — the API rejects a role
// added without one, so the button is what tells the user, not a 400.
const missingLicence =
businessRequired &&
Array.from(selected).some((type) => !licenceByType[type]);
const handleSave = () => {
if (selected.size === 0) return;
if (selected.size === 0 || missingLicence) return;
mutation.mutate(Array.from(selected));
};
@@ -142,25 +190,53 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
lockedNote={view?.note}
lockedNoteColor={view?.color}
detail={
(rejected || existing?.status === "suspended") &&
existing?.reviewNote
? `Reviewer note: ${existing.reviewNote}`
existing
? [
existing.etradeBusiness
? `Operating as: ${businessLabel(existing.etradeBusiness)}`
: businessRequired
? "No eTrade business attached yet"
: null,
(rejected || existing.status === "suspended") &&
existing.reviewNote
? `Reviewer note: ${existing.reviewNote}`
: null,
]
.filter(Boolean)
.join(" · ") || undefined
: undefined
}
action={
rejected ? (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
existing ? (
<Stack gap="xs">
{businessRequired && (
<EtradeBusinessSelect
label="Operating as"
value={existing.etradeBusiness?.licenceNumber ?? null}
disabled={attachMutation.isPending}
onChange={(licenceNumber) =>
attachMutation.mutate({
profileId: existing.id,
licenceNumber,
})
}
/>
)}
{rejected && (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
)}
</Stack>
) : undefined
}
onClick={() => toggle(opt.type)}
@@ -170,6 +246,29 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</SimpleGrid>
)}
{businessRequired && selected.size > 0 && (
<Stack gap="sm" mt="lg">
<Text size="sm" c="edr-muted">
Say which of your eTrade business licences each new role operates as.
</Text>
{options
.filter((opt) => selected.has(opt.type))
.map((opt) => (
<EtradeBusinessSelect
key={opt.type}
label={`${opt.label} operates as`}
value={licenceByType[opt.type] ?? null}
onChange={(licenceNumber) =>
setLicenceByType((prev) => ({
...prev,
[opt.type]: licenceNumber,
}))
}
/>
))}
</Stack>
)}
{options.length > 0 && (
<Group
justify="space-between"
@@ -199,7 +298,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
disabled={selected.size === 0 || missingLicence}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}

View File

@@ -1,4 +1,4 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import type { ETradeBusinessOption, Freight, PaginatedResponse } from "@edr/types";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
@@ -219,14 +219,13 @@ export const api = {
companiesService.getDashboard,
),
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
"companies",
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
addCompanyProfiles: endpoint<
{ profiles: { type: string; licenceNumber?: string }[] },
CompanyProfileResponse[]
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
CompanyProfileResponse
>(
"companies",
@@ -234,6 +233,21 @@ export const api = {
companiesService.createCompanyProfile,
),
listEtradeBusinesses: endpoint<void, ETradeBusinessOption[]>(
"companies",
"listEtradeBusinesses",
companiesService.listEtradeBusinesses,
),
attachEtradeBusiness: endpoint<
{ profileId: string; licenceNumber: string },
CompanyProfileResponse
>(
"companies",
"attachEtradeBusiness",
companiesService.attachEtradeBusiness,
),
startOnboarding: endpoint<
{
companyType: string;

View File

@@ -282,6 +282,8 @@ export interface WagonCancellation {
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
/** Who caused the cut: CUSTOMER pays a fee, EDR never does. */
fault?: "CUSTOMER" | "EDR" | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { ETradeBusinessOption } from "@edr/types";
import type { CompanyIdentityState } from "./verifayda.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
@@ -83,6 +84,11 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
attributes: Record<string, any> | null;
/**
* The eTrade business licence this role operates as, or null when nothing is
* attached yet (or the company registered without eTrade).
*/
etradeBusiness: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string;
@@ -363,7 +369,7 @@ export const companiesService = {
},
addCompanyProfiles: async (payload: {
types: string[];
profiles: { type: string; licenceNumber?: string }[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
@@ -376,6 +382,7 @@ export const companiesService = {
createCompanyProfile: async (payload: {
type: ProfileTypeValue;
businessLicense?: string;
licenceNumber?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
@@ -384,6 +391,29 @@ export const companiesService = {
return unwrap(response.data);
},
/**
* The eTrade business licences held under this company's TIN. Empty for a
* co-operative or investor-licence company, which has no eTrade record.
*/
listEtradeBusinesses: async (): Promise<ETradeBusinessOption[]> => {
const response = await client.get<ApiResponse<ETradeBusinessOption[]>>(
URL_CONSTANTS.COMPANIES_API.ETRADE_BUSINESSES,
);
return unwrap(response.data);
},
/** Attach (or re-attach) one of those businesses to an operational profile. */
attachEtradeBusiness: async (payload: {
profileId: string;
licenceNumber: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.patch<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_ETRADE_BUSINESS(payload.profileId),
{ licenceNumber: payload.licenceNumber },
);
return unwrap(response.data);
},
/** Begin onboarding — create the draft company + profile + role(s) up front. */
startOnboarding: async (payload: {
companyType: string;

View File

@@ -0,0 +1,87 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const E = URL_CONSTANTS.EMPTY_RETURN_REQUESTS;
export type EmptyReturnRequestStatus =
| "SUBMITTED"
| "APPROVED"
| "REJECTED"
| "PAID"
| "SCHEDULED"
| "COMPLETED"
| "CANCELLED";
export interface EmptyReturnRequest {
id: string;
bookingId: string;
status: EmptyReturnRequestStatus;
containerNumbers: string[];
containerCount: number;
quotedUnitAmount: number | null;
quotedTotalAmount: number | null;
currency: string | null;
invoiceId: string | null;
paidAt: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
rejectionReason: string | null;
submittedAt: string;
}
/** Per-container price for the return, off the booking's contract route rate. */
export interface EmptyReturnQuote {
unitAmount: number | null;
currency: string;
sourceRateUsd: number | null;
unavailableReason: string | null;
}
export interface EmptyReturnEligibility {
eligible: boolean;
reason: string | null;
availableContainerNumbers: string[];
maxContainers: number;
quote: EmptyReturnQuote;
}
export interface ScheduleEmptyReturnPayload {
returnDate: string;
truckPlateNumber: string;
truckDriverName: string;
truckType?: string;
}
/**
* Returning empties on a booking that was sold WITHOUT the return service:
* the customer names the containers, EDR prices and approves, the customer
* pays and then books the date and truck.
*/
export const emptyReturnRequestsService = {
/** Whether this booking can ask, which containers are free, and the price. */
eligibility: async (bookingId: string): Promise<EmptyReturnEligibility> => {
const { data } = await client.get(E.ELIGIBILITY(bookingId));
return data.data ?? data;
},
listForBooking: async (bookingId: string): Promise<EmptyReturnRequest[]> => {
const { data } = await client.get(E.BY_BOOKING(bookingId));
return data.data ?? data;
},
create: async (bookingId: string, containerNumbers: string[]): Promise<EmptyReturnRequest> => {
const { data } = await client.post(E.BASE, { bookingId, containerNumbers });
return data.data ?? data;
},
/** Date + truck, once the invoice is paid. */
schedule: async (
id: string,
payload: ScheduleEmptyReturnPayload,
): Promise<EmptyReturnRequest> => {
const { data } = await client.post(E.SCHEDULE(id), payload);
return data.data ?? data;
},
};

View File

@@ -1,108 +1,551 @@
import * as XLSX from 'xlsx';
import * as XLSX from "xlsx";
import {
CUSTOMER_TRUCK_TYPES,
ISO_CONTAINER_NUMBER,
type Freight,
} from "@edr/types";
export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void {
const data = [
{
'Truck Plate Number': '3-12345/67890',
'Driver Name': 'John Doe',
'Truck Type': 'Flatbed',
'Container 1': 'MAEU1234567',
'Container 2': 'HLXU7654321',
},
{
'Truck Plate Number': '3-98765/43210',
'Driver Name': 'Jane Smith',
'Truck Type': 'Flatbed',
'Container 1': 'COSCO1111111',
'Container 2': '',
},
];
import type { BookingDetail } from "@/pages/bookings/BookingDetailPage/booking-detail-types";
const instructions = [
['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'],
[],
['Column', 'Required', 'Notes'],
['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'],
['Driver Name', 'Yes', 'Full name of truck driver'],
['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'],
['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'],
['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'],
[],
['CONTAINER RULES'],
['- A 40ft container fills one truck (max 1 per truck)'],
['- Two 20ft containers fit on one truck (max 2 per truck)'],
['- No size mixing on same truck'],
['- Containers must be from the booking'],
[],
['Example Data Below →'],
];
/**
* Bulk self-haul truck assignment via Excel.
*
* The sheet a customer gets depends on what they booked. A container booking
* names the containers each truck carries; a bulk booking has no containers at
* all — the truck hauls loose tonnage — and a break-bulk/RoRo booking is counted
* in items (machinery units, vehicles), not tons. Emitting one fixed set of
* columns for all three is what made this unusable for anything but containers.
*
* Parsing follows the house pattern in
* `pages/contracts/new-shipment-form/container-excel.ts`: read the sheet as a
* grid, match headers fuzzily, report 1-based Excel row numbers, and return
* all-or-nothing so a half-valid file never posts.
*/
const wb = XLSX.utils.book_new();
/** Two 20ft containers fit a truck bed; one 40ft fills it. Mirrors the API's `MAX_CONTAINERS_PER_TRUCK`. */
const MAX_CONTAINERS_PER_TRUCK = 2;
// Instructions sheet
const wsInstructions = XLSX.utils.aoa_to_sheet(instructions);
wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }];
XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions');
export type TruckTemplateShape = "CONTAINER" | "PER_TON" | "PER_ITEM";
// Data template sheet
const wsData = XLSX.utils.json_to_sheet(data, {
header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'],
});
wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }];
XLSX.utils.book_append_sheet(wb, wsData, 'Trucks');
XLSX.writeFile(wb, filename);
export interface TruckTemplateContainer {
number: string;
/** "20ft" / "40ft", or "" when the booking line never recorded one. */
size: string;
/** Already riding another truck on this booking. */
assigned: boolean;
}
export function parseTruckAssignmentFile(
export interface TruckTemplateContext {
shape: TruckTemplateShape;
reference: string;
/** Human label for the cargo, e.g. "Machinery (MACHINERY)" or "Containerised cargo". */
cargoLabel: string;
/** Unit noun for PER_ITEM cargo — "machinery units", "vehicles", "items". */
itemNoun: string;
containers: TruckTemplateContainer[];
/** Bulk only: tonnage still to be hauled, when the booking declares a total. */
remainingTons: number | null;
}
/** A 40ft (or an unrecorded size, treated as one) fills the bed and travels alone. */
export function isTwentyFoot(size: string): boolean {
return size.includes("20");
}
/**
* PER_ITEM cargo is counted in pieces, and the piece has a name the customer
* recognises. RoRo bookings ride in as TRUCK / AUTOMOBILE / CARS.
*/
function itemNounFor(code: string | undefined, name: string | undefined): string {
switch ((code ?? "").toUpperCase()) {
case "TRUCK":
case "AUTOMOBILE":
case "CARS":
case "RORO":
return "vehicles";
case "MACHINERY":
return "machinery units";
default:
return name ? `${name.trim().toLowerCase()} items` : "items";
}
}
/**
* Everything the template and the parser need about one booking. Derived from
* data the detail endpoint already returns — no extra request.
*/
export function truckTemplateContext(
booking: BookingDetail,
trucks: Freight.ICustomerTruck[] = [],
): TruckTemplateContext {
const assigned = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
);
// Container size lives on the booking_container LINE; the physical numbers
// live on its units. Walk both to get number → size.
const containers: TruckTemplateContainer[] = [];
for (const line of booking.bookingContainers ?? []) {
const size =
line.containerSize?.trim() ||
(line.containerType?.sizeFt ? `${line.containerType.sizeFt}ft` : "");
for (const unit of line.units ?? []) {
if (!unit.containerNumber) continue;
containers.push({
number: unit.containerNumber,
size,
assigned: assigned.has(unit.containerNumber),
});
}
}
// Fall back to the flat list when the line relation didn't come through — the
// numbers are still usable, we just can't police sizes client-side.
if (containers.length === 0) {
for (const number of booking.containerNumbers ?? []) {
containers.push({ number, size: "", assigned: assigned.has(number) });
}
}
const isContainer = String(booking.freightType) === "CONTAINER";
const unit = booking.cargoType?.unitOfMeasure;
const shape: TruckTemplateShape = isContainer
? "CONTAINER"
: unit === "PER_ITEM"
? "PER_ITEM"
: // PER_TON, NUMBER_OF_WAGONS and unset all haul tonnage by truck.
"PER_TON";
const cargoLabel = isContainer
? "Containerised cargo"
: booking.cargoType?.cargoTypeName
? `${booking.cargoType.cargoTypeName.trim()}${booking.cargoType.code ? ` (${booking.cargoType.code})` : ""}`
: "Bulk cargo";
// Planned tonnage already committed to live trucks draws the total down; the
// API applies the same rule on every add.
const totalTons = Number(booking.cargoTotalWeightVgm ?? 0);
const committed = trucks.reduce((sum, t) => sum + Number(t.plannedTons ?? 0), 0);
const remainingTons =
!isContainer && totalTons > 0
? Math.max(0, Math.round((totalTons - committed) * 1000) / 1000)
: null;
return {
shape,
reference: booking.reference ?? "",
cargoLabel,
itemNoun: itemNounFor(booking.cargoType?.code, booking.cargoType?.cargoTypeName),
containers,
remainingTons,
};
}
const PLATE_HEADER = "Truck Plate Number";
const DRIVER_HEADER = "Driver Name";
const TYPE_HEADER = "Truck Type";
const CONTAINER_1_HEADER = "Container 1";
const CONTAINER_2_HEADER = "Container 2";
const TONS_HEADER = "Planned Tons";
const QUANTITY_HEADER = "Planned Quantity";
function headersFor(shape: TruckTemplateShape): string[] {
const base = [PLATE_HEADER, DRIVER_HEADER, TYPE_HEADER];
if (shape === "CONTAINER") return [...base, CONTAINER_1_HEADER, CONTAINER_2_HEADER];
if (shape === "PER_ITEM") return [...base, QUANTITY_HEADER, TONS_HEADER];
return [...base, TONS_HEADER];
}
/** Sample rows built from the booking's own data, so the customer edits rather than invents. */
function sampleRowsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
const type = CUSTOMER_TRUCK_TYPES[0];
if (ctx.shape === "CONTAINER") {
const free = ctx.containers.filter((c) => !c.assigned);
if (free.length === 0) {
return [["3-12345 ET", "Abebe Kebede", type, "", ""]];
}
const rows: Array<Array<string | number>> = [];
let i = 0;
let plate = 12345;
while (i < free.length) {
const first = free[i];
// Pair only two 20ft; a 40ft (or an unknown size) takes the truck alone.
const second =
isTwentyFoot(first.size) && free[i + 1] && isTwentyFoot(free[i + 1].size)
? free[i + 1]
: null;
rows.push([
`3-${plate++} ET`,
"Abebe Kebede",
type,
first.number,
second?.number ?? "",
]);
i += second ? 2 : 1;
if (rows.length >= 5) break;
}
return rows;
}
const tons = ctx.remainingTons && ctx.remainingTons > 0 ? Math.min(30, ctx.remainingTons) : 30;
if (ctx.shape === "PER_ITEM") {
return [["3-12345 ET", "Abebe Kebede", type, 2, tons]];
}
return [["3-12345 ET", "Abebe Kebede", type, tons]];
}
function instructionsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
const rows: Array<Array<string | number>> = [
["TRUCK ASSIGNMENT — BULK UPLOAD"],
[],
["Booking", ctx.reference],
["Cargo", ctx.cargoLabel],
[],
["Fill in the 'Trucks' sheet. One row per truck. Do not rename the headers."],
[],
["Column", "Required", "Notes"],
[PLATE_HEADER, "Yes", "The truck's plate, as written on the vehicle."],
[DRIVER_HEADER, "Yes", "Full name of the driver."],
[TYPE_HEADER, "Yes", `One of: ${CUSTOMER_TRUCK_TYPES.join(", ")}`],
];
if (ctx.shape === "CONTAINER") {
rows.push(
[CONTAINER_1_HEADER, "Yes", "A container number from this booking — see the 'Containers' sheet."],
[CONTAINER_2_HEADER, "No", "Only when pairing two 20ft containers on one truck."],
[],
["TRUCK CAPACITY"],
["One 40ft container fills a truck and travels alone."],
["Two 20ft containers may share one truck."],
["Never mix a 40ft and a 20ft on the same truck."],
["Each container may be assigned to exactly one truck."],
);
} else if (ctx.shape === "PER_ITEM") {
rows.push(
[QUANTITY_HEADER, "Yes", `Whole number of ${ctx.itemNoun} on this truck.`],
[TONS_HEADER, "No", "Weight in tonnes, if known."],
[],
["This cargo is counted in items, not containers — leave containers out entirely."],
);
} else {
rows.push(
[TONS_HEADER, "Yes", "Tonnes this truck will haul. Decimals allowed."],
[],
["This is bulk cargo — the truck hauls loose tonnage and is weighed on exit."],
);
}
if (ctx.remainingTons != null) {
rows.push([], ["Tonnage still to be hauled", `${ctx.remainingTons} t`]);
}
return rows;
}
/** Build and download the template for this booking. */
export function downloadTruckAssignmentTemplate(
ctx: TruckTemplateContext,
filename?: string,
): void {
const workbook = XLSX.utils.book_new();
const instructions = XLSX.utils.aoa_to_sheet(instructionsFor(ctx));
instructions["!cols"] = [{ wch: 24 }, { wch: 12 }, { wch: 62 }];
XLSX.utils.book_append_sheet(workbook, instructions, "Instructions");
const headers = headersFor(ctx.shape);
const trucks = XLSX.utils.aoa_to_sheet([headers, ...sampleRowsFor(ctx)]);
trucks["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
XLSX.utils.book_append_sheet(workbook, trucks, "Trucks");
if (ctx.shape === "CONTAINER") {
const reference = XLSX.utils.aoa_to_sheet([
["Container Number", "Size", "Status"],
...ctx.containers.map((c) => [
c.number,
c.size || "unknown",
c.assigned ? "Already on a truck" : "Available",
]),
]);
reference["!cols"] = [{ wch: 20 }, { wch: 10 }, { wch: 20 }];
XLSX.utils.book_append_sheet(workbook, reference, "Containers");
}
XLSX.writeFile(
workbook,
filename ?? `truck-assignments-${ctx.reference || "booking"}.xlsx`,
);
}
/** Normalised header key — tolerates case, spaces, punctuation and stray units. */
function headerKey(raw: string): string {
return String(raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
/**
* Map each template column to the index it occupies in the uploaded sheet.
* Matching is by normalised substring so "Planned Tons (t)" still resolves;
* the two container columns are matched on their digit so "Container 1" can
* never be captured by the looser "container" test.
*/
function resolveColumns(headerRow: string[]): Record<string, number> {
const keys = headerRow.map(headerKey);
const find = (...candidates: string[]): number => {
for (const candidate of candidates) {
const index = keys.findIndex((k) => k.includes(candidate));
if (index >= 0) return index;
}
return -1;
};
return {
plate: find("plate", "truckplate"),
driver: find("driver"),
type: find("trucktype", "type"),
container1: find("container1", "containerone"),
container2: find("container2", "containertwo"),
tons: find("plannedtons", "tons", "weight"),
quantity: find("plannedquantity", "quantity", "count"),
};
}
const TRUCK_TYPE_LIST = CUSTOMER_TRUCK_TYPES.join(", ");
/** Case-insensitive match onto the canonical spelling the API expects. */
function canonicalTruckType(raw: string): string | null {
const needle = raw.trim().toLowerCase();
return CUSTOMER_TRUCK_TYPES.find((t) => t.toLowerCase() === needle) ?? null;
}
export interface ParsedTruckFile {
rows: Freight.AddCustomerTruckPayload[];
errors: string[];
/** Excel row number each parsed row came from, positionally aligned with `rows`. */
rowNumbers: number[];
}
/**
* Parse an uploaded sheet against this booking. Returns every problem at once —
* a customer fixing a 30-row file should not discover the mistakes one upload at
* a time — and returns no rows at all unless the whole file is clean.
*/
export async function parseTruckAssignmentFile(
file: File,
): Promise<
Array<{
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers?: string[];
}>
> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
ctx: TruckTemplateContext,
): Promise<ParsedTruckFile> {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
const sheet =
workbook.Sheets["Trucks"] ??
Object.values(workbook.Sheets).find((s) => s !== workbook.Sheets["Instructions"]) ??
Object.values(workbook.Sheets)[0];
reader.onload = (e) => {
try {
const data = e.target?.result as ArrayBuffer;
const wb = XLSX.read(data, { type: 'array' });
const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0];
if (!sheet) {
return { rows: [], errors: ["The file has no sheets."], rowNumbers: [] };
}
if (!wsData) {
reject(new Error('No data sheet found in Excel file'));
return;
}
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
const jsonData = XLSX.utils.sheet_to_json(wsData) as Array<Record<string, any>>;
// The header is the first row that names the plate column — customers add
// titles and notes above the table.
const headerIndex = grid.findIndex((row) =>
row.some((cell) => headerKey(cell).includes("plate")),
);
if (headerIndex < 0) {
return {
rows: [],
errors: [
`Could not find the "${PLATE_HEADER}" column. Use the downloaded template and keep its headers.`,
],
rowNumbers: [],
};
}
const trucks = jsonData.map((row) => {
const containers = [
row['Container 1'],
row['Container 2'],
]
.filter((c) => c && c.trim())
.map((c) => c.trim().toUpperCase());
const columns = resolveColumns(grid[headerIndex]);
const errors: string[] = [];
const rows: Freight.AddCustomerTruckPayload[] = [];
const rowNumbers: number[] = [];
return {
truckPlateNumber: row['Truck Plate Number']?.trim() || '',
driverName: row['Driver Name']?.trim() || '',
truckType: row['Truck Type']?.trim() || '',
containerNumbers: containers.length > 0 ? containers : undefined,
};
});
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
const alreadyAssigned = new Set(
ctx.containers.filter((c) => c.assigned).map((c) => c.number),
);
const plateSeen = new Map<string, number>();
const containerSeen = new Map<string, number>();
let tonsInFile = 0;
resolve(trucks);
} catch (error) {
reject(error);
}
for (let i = headerIndex + 1; i < grid.length; i++) {
const row = grid[i];
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: string): string => {
const index = columns[key];
return index >= 0 ? String(row[index] ?? "").trim() : "";
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
const plate = cell("plate");
const driver = cell("driver");
const rawType = cell("type");
// A trailing blank row is normal, not an error.
if (!plate && !driver && !rawType && row.every((c) => !String(c ?? "").trim())) {
continue;
}
let rowOk = true;
if (!plate) {
errors.push(`Row ${rowNo}: truck plate number is required.`);
rowOk = false;
}
if (!driver) {
errors.push(`Row ${rowNo}: driver name is required.`);
rowOk = false;
}
const truckType = canonicalTruckType(rawType);
if (!truckType) {
errors.push(
`Row ${rowNo}: truck type "${rawType || "—"}" is not accepted. Use one of: ${TRUCK_TYPE_LIST}.`,
);
rowOk = false;
}
const plateKey = plate.toUpperCase();
if (plate) {
const seenAt = plateSeen.get(plateKey);
if (seenAt) {
errors.push(`Row ${rowNo}: plate ${plate} already appears on row ${seenAt}.`);
rowOk = false;
} else {
plateSeen.set(plateKey, rowNo);
}
}
const payload: Freight.AddCustomerTruckPayload = {
truckPlateNumber: plateKey,
driverName: driver,
truckType: truckType ?? "",
};
if (ctx.shape === "CONTAINER") {
const numbers = [cell("container1"), cell("container2")]
.map((n) => n.trim().toUpperCase())
.filter(Boolean);
if (numbers.length === 0) {
errors.push(`Row ${rowNo}: at least one container number is required.`);
rowOk = false;
}
if (numbers.length > MAX_CONTAINERS_PER_TRUCK) {
errors.push(`Row ${rowNo}: a truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers.`);
rowOk = false;
}
if (numbers.length === 2 && numbers[0] === numbers[1]) {
errors.push(`Row ${rowNo}: container ${numbers[0]} is listed twice on the same truck.`);
rowOk = false;
}
for (const number of numbers) {
if (!ISO_CONTAINER_NUMBER.test(number)) {
errors.push(
`Row ${rowNo}: container "${number}" is not a valid ISO number (four letters then seven digits, e.g. ABCD1234567).`,
);
rowOk = false;
continue;
}
if (ctx.containers.length > 0 && !containerSizes.has(number)) {
errors.push(`Row ${rowNo}: container ${number} is not on this booking.`);
rowOk = false;
continue;
}
if (alreadyAssigned.has(number)) {
errors.push(`Row ${rowNo}: container ${number} is already loaded onto another truck.`);
rowOk = false;
continue;
}
const seenAt = containerSeen.get(number);
if (seenAt) {
errors.push(`Row ${rowNo}: container ${number} is already used on row ${seenAt}.`);
rowOk = false;
continue;
}
containerSeen.set(number, rowNo);
}
// Pairing is allowed only when both are explicitly 20ft — a 40ft, or a
// container whose size was never recorded, fills the bed on its own. Same
// rule the API enforces in `assertTruckLoad`.
if (numbers.length === 2) {
const sizes = numbers.map((n) => containerSizes.get(n) ?? "");
if (sizes.some((size) => !isTwentyFoot(size))) {
errors.push(
`Row ${rowNo}: a truck carries either one 40ft container or two 20ft containers — ${numbers
.map((n, idx) => `${n} (${sizes[idx] || "size unknown"})`)
.join(" and ")} cannot share one.`,
);
rowOk = false;
}
}
payload.containerNumbers = numbers;
} else {
const tonsRaw = cell("tons");
const tons = Number(tonsRaw);
const quantityRaw = cell("quantity");
const quantity = Number(quantityRaw);
if (ctx.shape === "PER_ITEM") {
if (!quantityRaw || !Number.isInteger(quantity) || quantity <= 0) {
errors.push(
`Row ${rowNo}: planned quantity "${quantityRaw || "—"}" must be a whole number of ${ctx.itemNoun} greater than 0.`,
);
rowOk = false;
} else {
payload.plannedQuantity = quantity;
}
if (tonsRaw) {
if (Number.isNaN(tons) || tons <= 0) {
errors.push(`Row ${rowNo}: planned tons "${tonsRaw}" must be a number greater than 0.`);
rowOk = false;
} else {
payload.plannedTons = tons;
tonsInFile += tons;
}
}
} else {
if (!tonsRaw || Number.isNaN(tons) || tons <= 0) {
errors.push(
`Row ${rowNo}: planned tons "${tonsRaw || "—"}" must be a number greater than 0.`,
);
rowOk = false;
} else {
payload.plannedTons = tons;
tonsInFile += tons;
}
}
}
if (rowOk) {
rows.push(payload);
rowNumbers.push(rowNo);
}
}
if (
ctx.remainingTons != null &&
tonsInFile > ctx.remainingTons + 0.001 // numeric(14,3) — tolerate float drift
) {
errors.push(
`The file plans ${Math.round(tonsInFile * 1000) / 1000} t but only ${ctx.remainingTons} t are left to haul on this booking.`,
);
}
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no truck rows below the header.");
}
return errors.length > 0
? { rows: [], errors, rowNumbers: [] }
: { rows, errors: [], rowNumbers };
}