mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit
This commit is contained in:
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 1–2 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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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")),
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user