Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2

This commit is contained in:
yaschalew
2026-06-24 11:21:44 +03:00
142 changed files with 8687 additions and 2189 deletions

View File

@@ -12,6 +12,7 @@ import {
Paperclip,
Send,
Settings,
ShieldCheck,
SlidersHorizontal,
Train,
Truck,
@@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import GlClearancePage from "./pages/bookings/GlClearancePage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
@@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Operations",
items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
@@ -376,6 +384,14 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route
path="clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<GlClearancePage />
</RequirePermission>
}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />

View File

@@ -8,10 +8,22 @@ import {
Button,
Textarea,
FileInput,
NumberInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
/** Today + `days`, formatted as a readable date for the validity preview. */
function validUntilLabel(days: number): string {
const until = new Date();
until.setDate(until.getDate() + days);
return until.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -46,8 +58,18 @@ export function BookingConfirmDialog({
const Icon = action.icon;
const needsTextInput = action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const needsDaysInput = action.input === "days";
const needsAmountInput = action.input === "amount";
const daysValue = Number(inputValue.trim());
const daysValid =
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
const amountValue = Number(inputValue.trim());
const amountValid = !!inputValue.trim() && Number.isFinite(amountValue) && amountValue >= 0;
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile) ||
(needsDaysInput && !daysValid) ||
(needsAmountInput && !amountValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -129,6 +151,40 @@ export function BookingConfirmDialog({
clearable
/>
)}
{needsDaysInput && (
<Stack gap={4}>
<NumberInput
label={action.inputLabel ?? "Contract validity (days)"}
withAsterisk
min={1}
max={365}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
placeholder={action.inputPlaceholder ?? "e.g. 30"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
<Text size="xs" c="dimmed">
{daysValid
? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).`
: "Enter a whole number of days between 1 and 365."}
</Text>
</Stack>
)}
{needsAmountInput && (
<NumberInput
label={action.inputLabel ?? "Adjusted total"}
withAsterisk
min={0}
allowNegative={false}
decimalScale={2}
thousandSeparator=","
placeholder={action.inputPlaceholder ?? "0.00"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
)}
{extra}
</Stack>

View File

@@ -1,50 +1,171 @@
import { Banknote, Receipt } from "lucide-react";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import { useState } from "react";
import { Banknote, Pencil, Receipt } from "lucide-react";
import {
Button,
Divider,
Group,
NumberInput,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { BookingDetail } from "@/types/booking";
import { bookingsService } from "@/services/bookings.service";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
const modifiers = booking.cargoModifiers ?? [];
const qc = useQueryClient();
const computed = Number(booking.totalAmount);
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed;
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | "">(effective);
const [reason, setReason] = useState("");
const adjustMutation = useMutation({
mutationFn: (payload: { amount: number | null; reason?: string }) =>
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
onSuccess: () => {
toast.success("Price updated");
setEditing(false);
qc.invalidateQueries({ queryKey: ["bookings"] });
},
onError: () => toast.error("Could not update price"),
});
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
return (
<SectionCard icon={Banknote} title="Pricing & payment">
<Stack gap="md">
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Total amount
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</div>
{!editing && (
<Button
size="compact-xs"
variant="light"
leftSection={<Pencil size={13} />}
onClick={() => {
setAmount(effective);
setEditing(true);
}}
>
Adjust
</Button>
)}
</Group>
{editing && (
<Stack gap="xs" mt="md">
<NumberInput
label="New total"
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
min={0}
radius="md"
prefix={`${booking.paymentCurrency} `}
thousandSeparator=","
/>
<Textarea
label="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
/>
<Group justify="space-between" mt={4}>
{isAdjusted ? (
<Button
size="compact-sm"
variant="subtle"
color="red"
loading={adjustMutation.isPending}
onClick={() =>
adjustMutation.mutate({ amount: null })
}
>
Clear adjustment
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button
size="compact-sm"
variant="default"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
loading={adjustMutation.isPending}
disabled={amount === ""}
onClick={() =>
adjustMutation.mutate({
amount: Number(amount),
reason: reason.trim() || undefined,
})
}
>
Save
</Button>
</Group>
</Group>
</Stack>
)}
</Paper>
<Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
{lineItems.length > 0 && (
<>
<Divider color="var(--mantine-color-gray-2)" />
<Group gap={6}>
<Receipt size={13} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Surcharges applied
Price breakdown
</Text>
</Group>
<Stack gap="xs">
{modifiers.map((m) => (
{lineItems.map((li, i) => (
<Group
key={m.id}
key={`${li.code}-${i}`}
justify="space-between"
px="sm"
py={6}
@@ -55,10 +176,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
}}
>
<Text size="sm" c="dimmed">
Modifier
{li.description}
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(m.calculatedAmount).toLocaleString()}
{Number(li.amount).toLocaleString()} {li.currency}
</Text>
</Group>
))}

View File

@@ -1,14 +1,14 @@
import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) {
if (score >= 70) {
return (
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent
</Badge>
);
}
if (score >= 500) {
if (score >= 40) {
return (
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High

View File

@@ -9,6 +9,19 @@ import {
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
const days = Number(value.trim());
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/** An adjusted price must be a non-negative number. */
function isValidAmount(value: string): boolean {
if (!value.trim()) return false;
const amount = Number(value.trim());
return Number.isFinite(amount) && amount >= 0;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -59,15 +72,36 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
case "accept": {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.staffAccept.mutate(days, { onSuccess });
break;
}
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
case "reject":
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
break;
case "operationAccept":
mutations.reviewOperation.mutate({ decision: "ACCEPT" }, { onSuccess });
break;
case "operationRequestChanges":
mutations.reviewOperation.mutate(
{ decision: "REQUEST_CHANGES", note: inputValue.trim() },
{ onSuccess },
);
break;
case "operationAdjustPrice": {
const amount = Number(inputValue.trim());
if (!Number.isFinite(amount) || amount < 0) return;
mutations.reviewOperation.mutate(
{ decision: "ADJUST_PRICE", amount },
{ onSuccess },
);
break;
}
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
@@ -116,7 +150,9 @@ export function useBookingActionDialog(
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
return {
actions,

View File

@@ -167,7 +167,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
meta: {
title: "Configuration",
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
subtitle: "Master data: cargo, containers, wagon types, services, yards, and shipping lines",
},
},
...configurationRouteMeta,

View File

@@ -158,11 +158,21 @@ const RuleEngineFormDialog = ({
const visibleFields = useMemo(
() =>
fields.filter(
(field) =>
!field.hideWhen ||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
),
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
) {
return false;
}
return true;
}),
[fields, values],
);
@@ -244,6 +254,7 @@ const RuleEngineFormDialog = ({
<Select
key={field.name}
label={label}
description={field.description}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}

View File

@@ -229,9 +229,6 @@ export const URL_CONSTANTS = {
SERVICE_TYPES: "/service-types",
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
SURCHARGE_TYPES: "/surcharge-types",
SURCHARGE_TYPE_BY_ID: (id: string) => `/surcharge-types/${id}`,
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,

View File

@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
Coins,
FileSignature,
MessageSquareWarning,
Play,
@@ -34,9 +35,17 @@ export type BookingActionId =
| "allocateBooking"
| "startTransit"
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationAdjustPrice"
| "cancel";
export type BookingActionInputKind = "note" | "reason" | "file";
export type BookingActionInputKind =
| "note"
| "reason"
| "file"
| "days"
| "amount";
export interface BookingActionDef {
id: BookingActionId;
@@ -121,10 +130,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"The booking moves to pending approval and approval steps are created from the rule engine.",
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",
@@ -156,6 +168,50 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
id: "operationAccept",
label: "Accept operation",
shortLabel: "Accept",
description: "Accept the operation request and release it for dispatch",
confirmTitle: "Accept operation request?",
confirmDescription:
"Train orders enter the batch pool; road orders move to truck dispatch.",
variant: "default",
icon: Check,
primary: true,
},
{
id: "operationRequestChanges",
label: "Request changes",
shortLabel: "Changes",
description: "Ask the customer to adjust the operation request",
confirmTitle: "Request changes to the operation?",
confirmDescription:
"The customer will see your note and can adjust and resubmit the order.",
variant: "outline",
icon: MessageSquareWarning,
input: "note",
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
{
id: "operationAdjustPrice",
label: "Adjust price",
shortLabel: "Price",
description: "Set an adjusted total the customer must confirm",
confirmTitle: "Adjust the order price?",
confirmDescription:
"Enter the new total. The customer must confirm it before the order proceeds.",
variant: "outline",
icon: Coins,
input: "amount",
inputLabel: "Adjusted total",
inputPlaceholder: "0.00",
},
];
const CANCEL_ACTION: BookingActionDef = {
id: "cancel",
label: "Cancel booking",
@@ -207,6 +263,9 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -300,6 +359,9 @@ export function getBookingActions(
},
];
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "PAID":
if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })

View File

@@ -86,6 +86,22 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Consolidated",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
OPERATION_REQUEST_PENDING: {
label: "Operation Review",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
OPERATION_CHANGES_REQUESTED: {
label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
ROAD_DISPATCH_PENDING: {
label: "Truck Dispatch",
color: "bg-blue-50 text-blue-700 border-blue-200",
},
};
export interface StatusMeta {
@@ -257,10 +273,19 @@ export const BOOKING_LIST_TABS = [
"EXPIRED",
],
},
{
key: "ops_review",
label: "Ops review",
statuses: [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
],
},
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT"],
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },

View File

@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
};
const staffAccept = useMutation({
mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
});
@@ -60,6 +61,16 @@ export function useBookingMutations(bookingId: string) {
onError: () => toast.error("Failed to reject booking"),
});
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
note?: string;
amount?: number;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),
});
const approveStep = useMutation({
mutationFn: ({
stepId,
@@ -147,12 +158,14 @@ export function useBookingMutations(bookingId: string) {
payBooking.isPending ||
startTransit.isPending ||
complete.isPending ||
reviewOperation.isPending ||
cancel.isPending;
return {
staffAccept,
requestChanges,
staffReject,
reviewOperation,
approveStep,
rejectStep,
generateContract,

View File

@@ -96,6 +96,40 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
/**
* Cargo-type options restricted to LEAF nodes (actual commodities, not parent
* groups). A node is a leaf when no other cargo type names it as parent. Used
* by the Rate form's "Bulk cargo type" picker.
*/
export const useCargoLeafOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled,
select: (result) => {
const rows = result.data ?? [];
const parentIds = new Set(
rows
.map((row) => row.parentGroupId)
.filter((id): id is string => Boolean(id))
.map((id) => String(id)),
);
return rows
.filter((row) => row.id && !parentIds.has(String(row.id)))
.map((row) => {
const name = String(row.cargoTypeName ?? "").trim();
const code = String(row.code ?? "").trim();
const label =
name && code ? `${name} (${code})` : name || code || String(row.id);
return { label, value: String(row.id) };
});
},
});
export function buildContainerTypeSelectOptions(
rows: RuleEngineRecord[],
includeNone: boolean,

View File

@@ -15,6 +15,9 @@ export const FREIGHT_PERMS = {
signStaff: "edr_freight_app:bookings:sign_staff",
operations: "edr_freight_app:bookings:operations",
cancel: "edr_freight_app:bookings:cancel",
reviewDocuments: "edr_freight_app:bookings:review_documents",
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",

View File

@@ -1,11 +1,7 @@
import { type FormEvent, useState } from "react";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import {
Eye,
EyeOff,
Mail,
Smartphone,
UserRound,
ArrowUpRight,
Globe,
ChevronDown,
@@ -14,65 +10,15 @@ import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
type LoginMode = "email" | "phone" | "username";
const loginModes: Array<{
value: LoginMode;
label: string;
icon: typeof Mail;
placeholder: string;
}> = [
{
value: "email",
label: "Email",
icon: Mail,
placeholder: "name@company.com",
},
{
value: "phone",
label: "Phone",
icon: Smartphone,
placeholder: "09XXXXXXXX",
},
{
value: "username",
label: "Username",
icon: UserRound,
placeholder: "username",
},
];
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/;
const normalizeIdentifier = (mode: LoginMode, value: string) => {
const trimmed = value.trim();
if (mode === "email") {
if (!emailPattern.test(trimmed.toLowerCase())) {
throw new Error("Enter a valid email address.");
}
return trimmed.toLowerCase();
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
const normaliseIdentifier = (raw: string): string => {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
if (mode === "phone") {
const parsed = parsePhoneNumberFromString(trimmed, "ET");
if (!parsed?.isValid()) {
throw new Error("Enter a valid Ethiopian phone number.");
}
return parsed.number;
}
if (!usernamePattern.test(trimmed)) {
throw new Error(
"Username must be 3-32 characters and use letters, numbers, ., _, or -.",
);
}
return trimmed;
return v.toLowerCase();
};
const LOGIN_IMAGE = "/assets/login.png";
@@ -214,7 +160,6 @@ const FormFooter = () => (
const LoginPage = () => {
const navigate = useNavigate();
const { login, verifyMfa } = useAuth();
const [mode, setMode] = useState<LoginMode>("email");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [otp, setOtp] = useState("");
@@ -224,15 +169,13 @@ const LoginPage = () => {
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
const [error, setError] = useState<string | null>(null);
const currentMode = loginModes.find((item) => item.value === mode)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const normalized = normalizeIdentifier(mode, identifier);
const normalized = normaliseIdentifier(identifier);
setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password });
@@ -284,32 +227,14 @@ const LoginPage = () => {
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Sign in method
</label>
<div className="relative">
<select
value={mode}
onChange={(event) => setMode(event.target.value as LoginMode)}
className={`${fieldClass} appearance-none pr-10`}
>
{loginModes.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMode.label} <span className="text-red-500">*</span>
Email or Phone <span className="text-red-500">*</span>
</label>
<input
type="text"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMode.placeholder}
placeholder="name@company.com or 09XXXXXXXX"
autoComplete="username"
className={fieldClass}
/>
</div>

View File

@@ -0,0 +1,765 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Loader,
Progress,
ScrollArea,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
ExternalLink,
FileText,
Inbox,
MessageSquareWarning,
Search,
ShieldCheck,
Upload,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { bookingsService } from "@/services/bookings.service";
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
export default function GlClearancePage() {
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
// Bookings currently awaiting GL document review.
const { data: list, isLoading } = useQuery({
queryKey: ["gl-clearance", "list"],
queryFn: () =>
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
});
const bookings = list?.items ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return bookings;
return bookings.filter(
(b) =>
b.reference?.toLowerCase().includes(q) ||
b.tradeDirection?.toLowerCase().includes(q) ||
b.freightType?.toLowerCase().includes(q),
);
}, [bookings, search]);
const activeId =
selectedId && filtered.some((b) => b.id === selectedId)
? selectedId
: (filtered[0]?.id ?? null);
return (
<PageContainer>
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{bookings.length} awaiting review
</Badge>
}
/>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ── Review queue ─────────────────────────────────────────────── */}
<Card
withBorder
shadow="sm"
radius="lg"
p="sm"
className="w-full shrink-0 lg:w-[320px]"
>
<Group justify="space-between" align="center" mb="xs" px={4}>
<Text fz="13px" fw={700} c="edr-text">
Review queue
</Text>
<Badge size="sm" variant="default" radius="sm">
{filtered.length}
</Badge>
</Group>
<TextInput
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search reference…"
size="xs"
radius="md"
mb="xs"
leftSection={<Search size={14} />}
rightSection={
search ? (
<X
size={14}
style={{ cursor: "pointer" }}
onClick={() => setSearch("")}
/>
) : null
}
/>
{isLoading ? (
<Group justify="center" py="lg" gap={8}>
<Loader size="xs" color="edr-green" />
<Text fz="13px" c="dimmed">
Loading
</Text>
</Group>
) : filtered.length === 0 ? (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
<Inbox size={20} />
</ThemeIcon>
<Text fz="13px" c="dimmed" ta="center">
{search
? "No bookings match your search."
: "Nothing awaiting document review."}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
<Stack gap={6}>
{filtered.map((b) => (
<QueueItem
key={b.id}
booking={b}
active={b.id === activeId}
onSelect={() => setSelectedId(b.id)}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Card>
{/* ── Review panel ─────────────────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
{activeId ? (
<ClearanceReviewPanel
key={activeId}
bookingId={activeId}
onChanged={() =>
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
}
/>
) : (
<EmptyPanel />
)}
</Box>
</div>
</PageContainer>
);
}
/** A single booking row in the left-hand review queue. */
function QueueItem({
booking,
active,
onSelect,
}: {
booking: Freight.IBooking;
active: boolean;
onSelect: () => void;
}) {
return (
<Box
component="button"
type="button"
onClick={onSelect}
ta="left"
p="xs"
style={{
cursor: "pointer",
borderRadius: 12,
border: "1px solid",
borderColor: active
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-6)",
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-edr-card-6)",
transition: "all 120ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap={8}>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Group gap={6} mt={3} wrap="nowrap">
<Badge
size="xs"
variant="light"
radius="sm"
color={
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
}
>
{booking.tradeDirection}
</Badge>
<Text fz="11px" c="edr-muted" truncate>
{booking.freightType}
</Text>
</Group>
</Box>
</Group>
</Box>
);
}
function EmptyPanel() {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Stack align="center" gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<ShieldCheck size={28} />
</ThemeIcon>
<Text fw={700} c="edr-text">
No booking selected
</Text>
<Text fz="13px" c="dimmed" ta="center" maw={320}>
Pick a booking from the review queue to inspect its customer documents
and start clearance.
</Text>
</Stack>
</Card>
);
}
function ClearanceReviewPanel({
bookingId,
onChanged,
}: {
bookingId: string;
onChanged: () => void;
}) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const { data: clearance, isLoading } = useQuery({
queryKey: ["gl-clearance", bookingId],
queryFn: () => bookingsService.getClearance(bookingId),
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
onChanged();
};
const reviewMutation = useMutation({
mutationFn: (p: {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) => bookingsService.reviewClearanceDocument(bookingId, p),
onSuccess: (_d, p) => {
toast.success(
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
);
if (p.status === "QUERIED")
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
refresh();
},
onError: () => toast.error("Could not update document"),
});
const outputMutation = useMutation({
mutationFn: () =>
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
refresh();
},
onError: () => toast.error("Upload failed"),
});
const finalizeMutation = useMutation({
mutationFn: () => bookingsService.finalizeClearance(bookingId),
onSuccess: () => {
toast.success("Clearance finalized");
refresh();
},
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "Could not finalize clearance",
),
});
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
// Review progress across the customer documents — drives the summary bar.
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
(d) => d.reviewStatus === "APPROVED",
).length;
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
return { total, approved, queried, pending };
}, [customerDocs]);
if (isLoading || !clearance) {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Group justify="center" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</Card>
);
}
const progressPct =
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
return (
<Stack gap="md">
{/* ── Progress summary ───────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md">
<Box>
<Text fw={700} fz="15px" c="edr-text">
Customer documents
</Text>
<Text fz="12.5px" c="dimmed" mt={2}>
Approve each document, or open a query to tell the customer what to
fix.
</Text>
</Box>
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
size="lg"
leftSection={<CheckCircle2 size={14} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="edr-blue"
radius="sm"
size="lg"
leftSection={<Clock size={14} />}
>
Review pending
</Badge>
)}
</Group>
<Progress
value={progressPct}
color="edr-green"
radius="xl"
size="sm"
mb="sm"
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
<Text fz="12.5px" c="dimmed" ml="auto">
{stats.approved}/{stats.total} approved
</Text>
</Group>
</Card>
{/* ── Document review list ───────────────────────────────────────── */}
<Stack gap={12}>
{customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))}
</Stack>
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
{clearance.outputCode && (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md">
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
<Upload size={15} />
</ThemeIcon>
<Text fw={700} c="edr-text">
Customs output documents
</Text>
</Group>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-blue"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
</Group>
</Group>
))}
</Stack>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={outputMutation.isPending}
onClick={() => outputMutation.mutate()}
>
Upload output documents
</Button>
</Group>
</Card>
)}
{finalizeMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error
? finalizeMutation.error.message
: "Could not finalize clearance."}
</Alert>
)}
{/* ── Finalize bar ───────────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="md">
<Group justify="space-between" wrap="nowrap">
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. You can finalize clearance."
: "Approve every required document to unlock finalization."}
</Text>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Card>
</Stack>
);
}
function StatPill({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="12.5px" c="edr-text" fw={600}>
{value}
</Text>
<Text fz="12.5px" c="dimmed">
{label}
</Text>
</Group>
);
}
/** Visual treatment for each document review state. */
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "edr-slate" },
};
function DocReviewCard({
doc,
note,
queryOpen,
onToggleQuery,
onNote,
onApprove,
onQuery,
busy,
}: {
doc: Freight.ClearanceDocument;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
return (
<Card
withBorder
shadow="sm"
radius="lg"
p="md"
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-blue" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile && (
<Tooltip label="Open document">
<Button
component="a"
href={doc.file!.url}
target="_blank"
rel="noreferrer"
size="compact-xs"
variant="default"
radius="md"
leftSection={<ExternalLink size={13} />}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>
{/* Previously raised query — visible so staff see what was asked. */}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{/* Action row — only when the customer actually uploaded a file. */}
{hasFile && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
</Group>
) : (
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
<Textarea
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
value={note}
onChange={(e) => onNote(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
size="sm"
autoFocus
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => onToggleQuery(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
loading={busy}
disabled={!note.trim()}
onClick={onQuery}
>
Send query to customer
</Button>
</Group>
</Box>
)}
</Box>
)}
</Card>
);
}

View File

@@ -22,7 +22,7 @@ import {
LayoutGrid,
Package,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -84,6 +84,9 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const approveMutation = useMutation(
api.customers.setCompanyStatus.mutationOptions(),
);
const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({
input: { id: id ?? "" },
@@ -398,6 +401,21 @@ export default function CustomerDetailPage() {
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
{company.status === "pending" && (
<Button
size="xs"
color="green"
loading={approveMutation.isPending}
onClick={() =>
approveMutation.mutate({
companyId: company.id,
status: "active",
})
}
>
Approve
</Button>
)}
</Group>
}
/>

View File

@@ -35,6 +35,7 @@ import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
getRuleEngineResource,
RULE_ENGINE_SELECT_NONE,
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import {
@@ -52,6 +53,8 @@ interface CargoNode extends RuleEngineRecord {
parentGroupId?: string | null;
showFreeTextBox?: boolean;
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
isActive?: boolean;
displayOrder?: number;
}
@@ -62,6 +65,21 @@ const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
/** Create/edit form fields. Parent is set from the current page, never picked. */
const FORM_FIELDS: FormFieldDef[] = [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{
// How this cargo is measured. Optional — leave "None" for grouping
// categories; set it on the actual commodities so bookings ask for the
// right amount (estimated tons vs. total item count).
name: "unitOfMeasure",
label: "Unit of measure",
type: "select",
optional: true,
placeholder: "Select unit (optional)",
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
{ label: "Per ton (bulk)", value: "PER_TON" },
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
],
},
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
@@ -469,6 +487,13 @@ function CargoRow({
</Badge>
</Tooltip>
) : null}
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
</Badge>
</Tooltip>
) : null}
{inactive ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Inactive

View File

@@ -29,6 +29,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import {
useApprovalChain,
useCargoLeafOptions,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
@@ -144,12 +145,17 @@ const RuleEngineResourcePage = () => {
const usesContainerTypeField = Boolean(
config?.formFields.some((f) => f.name === "containerTypeId"),
);
const usesCargoTypeField = Boolean(
config?.formFields.some((f) => f.name === "cargoTypeId"),
);
const usesLiveRateField = Boolean(
config?.formFields.some((f) => f.name === "rateId"),
);
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
useCargoLeafOptions(usesCargoTypeField);
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
@@ -173,6 +179,13 @@ const RuleEngineResourcePage = () => {
options: containerTypeOptions ?? [],
};
}
if (field.name === "cargoTypeId") {
return {
...field,
type: "select" as const,
options: cargoLeafOptions ?? [],
};
}
if (field.name === "rateId") {
return {
...field,
@@ -182,7 +195,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
}, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -316,7 +329,15 @@ const RuleEngineResourcePage = () => {
const handleFormSubmit = (values: Record<string, unknown>) => {
let payload = values;
if (config.slug === "rates") {
payload = { ...values, currency: "USD" };
// Base-freight categories have no surcharge trigger field — the engine
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
// chosen trigger.
const isSurcharge = values.appliesTo === "OTHER";
payload = {
...values,
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
} else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
@@ -476,6 +497,7 @@ const RuleEngineResourcePage = () => {
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}

View File

@@ -38,6 +38,12 @@ export interface FormFieldDef {
disabled?: boolean;
/** Hide this field when another field currently equals one of these values. */
hideWhen?: { field: string; equals: string[] };
/**
* Show this field ONLY when another field currently equals one of these
* values (inverse of hideWhen). When both are set, the field must satisfy
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
}
export interface RuleEngineOrderConfig {
@@ -81,38 +87,38 @@ const APPROVAL_ROLES = [
{ label: "CEO", value: "CEO" },
];
const SURCHARGE_TRIGGERS = [
{ label: "Hazardous cargo", value: "CARGO_FLAG_HAZARDOUS" },
{ label: "Reefer cargo", value: "CARGO_FLAG_REEFER" },
{ label: "VGM exceeds limit", value: "VGM_EXCEEDS_LIMIT" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE_MAPPED" },
{ label: "Consolidation enabled", value: "CONSOLIDATION_ENABLED" },
/**
* Friendly, admin-facing rate categories. Choosing one drives which fields the
* Rate form shows (see the `rates` resource below). Base-freight categories
* carry a trade direction + container/bulk scope; OTHER is for surcharges.
*/
const RATE_APPLIES_TO = [
{ label: "Bulk (base freight)", value: "BULK" },
{ label: "Container (base freight)", value: "CONTAINER" },
{ label: "Intercity (base freight)", value: "INTERCITY" },
{ label: "First mile", value: "FIRST_MILE" },
{ label: "Last mile", value: "LAST_MILE" },
{ label: "Other (surcharge)", value: "OTHER" },
];
const RATE_TYPES = [
"CONTAINER_IMPORT",
"CONTAINER_EXPORT",
"BULK_IMPORT",
"BULK_EXPORT",
"INTERCITY_BULK",
"INTERCITY_CONTAINER",
"FIRST_MILE",
"LAST_MILE",
"DEMURRAGE",
"LASHING",
"DOUBLE_HANDLING",
"CONTAINER_WITH_RETURN",
"CANCELLATION_FEE",
"OVERWEIGHT_PER_TON",
"HAZARD_SURCHARGE",
"REEFER_SURCHARGE",
"PIL_EXTRA_FEE",
].map((v) => ({ label: v.replace(/_/g, " "), value: v }));
/** Surcharge triggers — only relevant when Applies to = Other. */
const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{ label: "Reefer cargo", value: "REEFER" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
];
const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].map((v) => ({
label: v.replace(/_/g, " "),
value: v,
}));
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
(v) => ({
label: v.replace(/_/g, " "),
value: v,
}),
);
const CURRENCIES = [
{ label: "USD", value: "USD" },
@@ -302,38 +308,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "surcharge-types",
label: "Surcharge Types",
category: "configuration",
subtitle: "Auto-applied surcharge definitions",
searchPlaceholder: "Search surcharge types...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
{ id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{
name: "triggerCondition",
label: "Trigger condition",
type: "select",
required: true,
options: SURCHARGE_TRIGGERS,
},
{
name: "rateId",
label: "Live rate",
type: "select",
required: true,
placeholder: "Select a LIVE rate",
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "weight-limit-rules",
label: "Weight Limit Rules",
@@ -424,32 +398,64 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "rates",
label: "Rates",
category: "rules",
cardTitleKey: "rateType",
cardTitleKey: "appliesTo",
cardSubtitleKey: "currency",
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
columns: [
{ id: "rateType", header: "Type", accessorKey: "rateType", format: "code" },
{ id: "currency", header: "Currency", accessorKey: "currency" },
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
],
formFields: [
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
{
name: "appliesTo",
label: "Applies to",
type: "select",
required: true,
options: RATE_APPLIES_TO,
description:
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
},
// ── Surcharge trigger — only when Applies to = Other ──────────────────
{
name: "trigger",
label: "Surcharge trigger",
type: "select",
required: true,
options: RATE_TRIGGERS,
placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] },
},
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
},
// ── Container type — Container & Intercity ────────────────────────────
{
name: "containerTypeId",
label: "Container type",
type: "select",
optional: true,
placeholder: "Select container type (optional)",
showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] },
},
// ── Bulk cargo (leaf commodity) — Bulk & Intercity ───────────────────
{
name: "tradeDirection",
label: "Trade direction",
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
options: TRADE_DIRECTIONS,
optional: true,
placeholder: "Select bulk commodity (optional)",
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
},
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },

View File

@@ -1817,10 +1817,10 @@ export const api = {
({ id }) => bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string }, BookingDetail>(
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
"bookings",
"staffAccept",
({ id }) => bookingsService.staffAccept(id),
({ id, validityDays }) => bookingsService.staffAccept(id, validityDays),
),
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
@@ -1835,6 +1835,18 @@ export const api = {
({ id, reason }) => bookingsService.staffReject(id, reason),
),
reviewOperation: endpoint<
{
id: string;
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
note?: string;
amount?: number;
},
BookingDetail
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
bookingsService.reviewOperation(id, decision, { note, amount }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
"bookings",
"approveStep",
@@ -1947,6 +1959,18 @@ export const api = {
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",
({ companyId, status }) =>
customersService.setCompanyStatus(companyId, status),
undefined,
(input) => [
QUERY_KEYS.CUSTOMERS.byId(input.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
},
overview: {

View File

@@ -2,6 +2,7 @@ import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { BookingDetail } from "@/types/booking";
import type { Freight } from "@edr/types";
const B = URL_CONSTANTS.BOOKINGS;
@@ -169,7 +170,38 @@ export const bookingsService = {
await client.delete(B.BY_ID(id));
},
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
// ── Document clearance (GL workflow) ──
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
const response = await client.get(`/bookings/${id}/clearance`);
return unwrap(response.data) as Freight.ClearanceView;
},
reviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
uploadClearanceOutput: async (
id: string,
files: Record<string, File | null>,
): Promise<BookingDetail> => {
const form = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) form.append(key, file);
}
const response = await client.post(
`/bookings/${id}/clearance/output-documents`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as BookingDetail;
},
finalizeClearance: (id: string) =>
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
staffAccept: (id: string, validityDays: number) =>
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
requestChanges: (id: string, note: string) =>
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
@@ -177,6 +209,24 @@ export const bookingsService = {
staffReject: (id: string, reason: string) =>
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
/** Marketing/operations review of a drawdown order's operation request. */
reviewOperation: (
id: string,
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
options: { note?: string; amount?: number } = {},
) =>
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
decision,
...options,
}),
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
adjustPrice: (id: string, amount: number | null, reason?: string) =>
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
amount,
reason,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),

View File

@@ -88,4 +88,11 @@ export const customersService = {
)
.then((r) => r.data);
},
/** Approve / change a company's status (e.g. pending → active). */
setCompanyStatus(companyId: string, status: string): Promise<unknown> {
return apiClient
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
};

View File

@@ -30,7 +30,6 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
@@ -50,8 +49,6 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
case "service-types":
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
case "surcharge-types":
return URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPE_BY_ID(id);
case "weight-limit-rules":
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
case "yards":

View File

@@ -119,6 +119,24 @@ export interface BookingDetail {
status: BookingStatus;
scheduledDate: string;
totalAmount: number;
adjustedTotalAmount?: number | null;
adjustedByStaffId?: string | null;
adjustedAt?: string | null;
adjustmentReason?: string | null;
/** Contract validity window set by the backoffice when accepting. */
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
pricingBreakdown?: {
currency: string;
totalAmount: number;
lineItems: Array<{
code: string;
description: string;
amount: number;
currency: string;
}>;
} | null;
paymentStatus: string;
paymentCurrency: string;
contractType: string;
@@ -126,7 +144,6 @@ export interface BookingDetail {
tradeDirection: string;
cargoTotalWeightVgm: number;
isHazardous: boolean;
allowConsolidation: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number;

View File

@@ -4,7 +4,6 @@ export type RuleEngineResourceSlug =
| "wagon-types"
| "priority-configs"
| "service-types"
| "surcharge-types"
| "weight-limit-rules"
| "yards"
| "shipping-lines"

View File

@@ -23,12 +23,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
"react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
@@ -41,6 +43,7 @@
"@edr/tsconfig": "workspace:*",
"@hookform/devtools": "^4.4.0",
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View File

@@ -1,6 +1,7 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import {
CalendarCheck,
Clock,
Home,
Layers,
Loader2,
@@ -109,11 +110,13 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* as users who haven't completed onboarding.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const { company, onboardingCompleted, companyStatus } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Onboarding done but not yet approved by an admin → awaiting-approval state.
const awaitingApproval = !needsOnboarding && companyStatus === "pending";
// Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
@@ -146,6 +149,7 @@ function OnboardingGate() {
{needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} />
)}
{awaitingApproval && <PendingApprovalBanner />}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
@@ -177,6 +181,19 @@ function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
);
}
/** Shown after onboarding while the company awaits backoffice approval. */
function PendingApprovalBanner() {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-amber-300/50 bg-amber-50 px-6 py-3">
<Clock size={16} className="text-amber-700" />
<span className="text-sm font-medium text-amber-800">
Your company is awaiting EDR approval. You can browse, but creating
bookings is disabled until your company is approved.
</span>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
@@ -228,14 +245,7 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const {
user,
company,
activeProfileType,
companyType,
switchMode,
createProfileAndSwitch,
} = useAuth();
const { user, company, companyType, createProfileAndSwitch } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
@@ -278,8 +288,6 @@ const App = () => {
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
activeProfileType={activeProfileType}
onSwitchMode={switchMode}
onCreateProfile={createProfileAndSwitch}
>
<OnboardingGate />

View File

@@ -19,9 +19,7 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
ArrowLeftRight,
Bell,
Check,
ChevronDown,
FileSignature,
LogOut,
@@ -61,13 +59,9 @@ export interface AppLayoutProps {
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
/** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null;
/** The active operational mode (importer/exporter/...). */
activeProfileType?: string | null;
/** Switch to an existing profile of the given type. */
onSwitchMode?: (type: ServiceType) => Promise<SwitchResult> | void;
/** Create the profile of the given type (with business license) then switch. */
/** Create a new service profile of the given type (with business license). */
onCreateProfile?: (
type: ServiceType,
licenseFiles: File[],
@@ -146,8 +140,6 @@ export function AppLayout({
userEmail,
companyProfiles = [],
companyType,
activeProfileType,
onSwitchMode,
onCreateProfile,
children,
}: AppLayoutProps) {
@@ -174,16 +166,16 @@ export function AppLayout({
const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath);
// ── Service selection (customer companies only) ──
// A customer can operate as importer, exporter and/or freight forwarder,
// and switch between whichever service profiles their company has.
// ── Add a service (customer companies only) ──
// A customer can operate as importer, exporter and/or freight forwarder. The
// header lets them ADD a service they don't have yet (creating a profile with
// its business license). Data is no longer scoped by an "active" service —
// every page shows all the company's data, with an optional per-page filter.
const isCustomer = companyType === "customer";
const canSwitch =
isCustomer &&
CUSTOMER_SERVICES.includes(activeProfileType as ServiceType);
const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type);
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
const canAddService = isCustomer && addableServices.length > 0;
const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -191,22 +183,12 @@ export function AppLayout({
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const handleSelectService = async (type: ServiceType) => {
if (type === activeProfileType) return;
if (profileExists(type)) {
setSwitching(true);
try {
await onSwitchMode?.(type);
} finally {
setSwitching(false);
}
} else {
// No profile yet — collect a business license, then create + switch.
setCreateTarget(type);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
}
const handleAddService = (type: ServiceType) => {
// Collect a business license, then create the profile.
setCreateTarget(type);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
};
const handleCreateConfirm = async () => {
@@ -302,8 +284,8 @@ export function AppLayout({
{/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Service selector (customer companies only) */}
{canSwitch && (
{/* Add a service (customer companies that don't yet have all three) */}
{canAddService && (
<Menu
width={220}
position="bottom-end"
@@ -319,43 +301,25 @@ export function AppLayout({
color="edr-green"
radius={999}
size="sm"
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />}
leftSection={<Plus size={15} strokeWidth={1.8} />}
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
styles={{ root: { height: 36 } }}
visibleFrom="xs"
>
{serviceLabel(activeProfileType as ServiceType)}
Add service
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Select service</Menu.Label>
{CUSTOMER_SERVICES.map((type) => {
const isActive = type === activeProfileType;
const exists = profileExists(type);
return (
<Menu.Item
key={type}
onClick={() => handleSelectService(type)}
leftSection={
isActive ? (
<Check size={15} strokeWidth={2} />
) : exists ? (
<ArrowLeftRight size={15} strokeWidth={1.8} />
) : (
<Plus size={15} strokeWidth={1.8} />
)
}
disabled={isActive}
>
{serviceLabel(type)}
{!exists && (
<Text span size="xs" c="dimmed" ml={6}>
(set up)
</Text>
)}
</Menu.Item>
);
})}
<Menu.Label>Add a service</Menu.Label>
{addableServices.map((type) => (
<Menu.Item
key={type}
onClick={() => handleAddService(type)}
leftSection={<Plus size={15} strokeWidth={1.8} />}
>
{serviceLabel(type)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
@@ -468,39 +432,25 @@ export function AppLayout({
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => {
const isActive = p.type === activeProfileType;
return (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
<Group gap={6} wrap="nowrap">
{isActive && (
<Check
size={13}
color={primaryDarkColor}
strokeWidth={2.5}
/>
)}
<Text
size="xs"
fw={isActive ? 700 : 600}
style={{
color: isActive ? primaryDarkColor : textColor,
}}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
</Group>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
);
})}
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>

View File

@@ -1,55 +0,0 @@
import { Badge, Tooltip } from "@mantine/core";
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { modeDataDescription, modeDataLabel } from "@/constants/profileMode";
interface ModeIndicatorProps {
/** Mantine size token for the badge. */
size?: "sm" | "md" | "lg";
}
/**
* Small pill showing which operational mode's data is currently on screen
* (Import / Export). The data itself is scoped server-side by the active
* profile; this just makes the scope visible. Switching is done via the header
* button — this is read-only.
*
* Renders nothing for non-customer companies or when no import/export mode is
* active, so it never interferes with forwarders or not-yet-onboarded users.
*/
export function ModeIndicator({ size = "md" }: ModeIndicatorProps) {
const { companyType, activeProfileType } = useAuth();
if (companyType !== "customer") return null;
const label = modeDataLabel(activeProfileType);
if (!label) return null;
const isImport = activeProfileType === "importer";
return (
<Tooltip label={modeDataDescription(activeProfileType)} withArrow>
<Badge
size={size}
radius="sm"
variant="light"
color={isImport ? "edr-green" : "blue"}
leftSection={
isImport ? (
<ArrowDownToLine size={13} />
) : (
<ArrowUpFromLine size={13} />
)
}
styles={{
root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 },
}}
>
Viewing: {label}
</Badge>
</Tooltip>
);
}
export default ModeIndicator;

View File

@@ -51,7 +51,7 @@ export function PhoneField({
label,
value,
onChange,
onBlur,
// onBlur,
error,
required,
disabled,

View File

@@ -33,7 +33,7 @@ export default function ETradeInfo({
const hasData = mutation.data;
const handleFetch = async () => {
if (!tin || tin.length !== 10) return;
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
const result = await mutation.mutateAsync(tin);
if (result) {
onDataLoaded(result);
@@ -50,8 +50,8 @@ export default function ETradeInfo({
<Stack gap="md">
<Group align="flex-start" grow>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="0012345678"
maxLength={10}
error={error}
{...register}
@@ -60,7 +60,7 @@ export default function ETradeInfo({
variant="filled"
color="edr-green"
onClick={handleFetch}
disabled={!tin || tin.length !== 10 || isLoading}
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
leftSection={
isLoading ? <Loader size={16} /> : <Download size={16} />
}

View File

@@ -1,6 +1,6 @@
/**
* Operational-mode (importer/exporter/…) labels and helpers, shared by the app
* header and the per-page mode indicator so there is a single source of truth.
* Operational-service (importer/exporter/…) display labels, shared by the app
* header and the per-page service filters so there is a single source of truth.
*/
export const PROFILE_TYPE_LABELS: Record<string, string> = {
@@ -10,21 +10,3 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */
export function modeDataLabel(
activeProfileType?: string | null,
): string | null {
if (activeProfileType === "importer") return "Import";
if (activeProfileType === "exporter") return "Export";
return null;
}
/** Short helper sentence describing what the active mode scopes. */
export function modeDataDescription(
activeProfileType?: string | null,
): string {
const label = modeDataLabel(activeProfileType);
if (!label) return "";
return `Showing your ${label.toLowerCase()} data — switch in the header.`;
}

View File

@@ -157,6 +157,9 @@ const useAuth = () => {
const activeCompanyProfileId =
companyInfo?.profile?.activeCompanyProfileId ?? null;
const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active).
const isCompanyApproved = companyStatus === "active";
const onboardingCompleted =
companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
@@ -230,6 +233,8 @@ const useAuth = () => {
activeProfileType,
activeCompanyProfileId,
companyType,
companyStatus,
isCompanyApproved,
onboardingCompleted,
onboardingStep,
switchMode,

View File

@@ -1,7 +1,9 @@
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { Grid, Stack } from "@mantine/core";
import { Group, Grid, Select, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import {
FreightVolumeSection,
HelloSection,
@@ -15,8 +17,12 @@ import { useMyPortalData } from "./hooks";
export default function MyPortalPage() {
const navigate = useNavigate();
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const {
customer,
companyProfiles,
bookingsQuery,
dashboardQuery,
allBookings,
@@ -30,7 +36,12 @@ export default function MyPortalPage() {
dashboard,
volumePoints,
maxVolume,
} = useMyPortalData();
} = useMyPortalData(selectedProfileId ?? undefined);
const serviceOptions = companyProfiles.map((p) => ({
value: p.id,
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`);
@@ -40,6 +51,22 @@ export default function MyPortalPage() {
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
{serviceOptions.length > 1 && (
<Group justify="flex-end">
<Select
placeholder="All services"
data={serviceOptions}
value={selectedProfileId}
onChange={setSelectedProfileId}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 220 }}
aria-label="Filter dashboard by service"
/>
</Group>
)}
<SetupPrompt show={!customer} />
<StatsSection

View File

@@ -2,7 +2,6 @@ import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { ModeIndicator } from "@/components/ModeIndicator";
import { cv } from "../constants";
interface HelloSectionProps {
@@ -24,7 +23,6 @@ export const HelloSection = memo(function HelloSection({
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
<ModeIndicator />
</Group>
</Box>

View File

@@ -5,17 +5,25 @@ import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData() {
const { user, customer } = useAuth();
export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const companyProfiles = company?.company?.companyProfiles ?? [];
const bookingsQuery = useQuery(
api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" },
input: {
sortBy: "createdAt",
sortOrder: "DESC",
companyProfileId: selectedProfileId,
},
}),
);
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
const dashboardQuery = useQuery(
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
);
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) =>
@@ -56,6 +64,7 @@ export function useMyPortalData() {
return {
user,
customer,
companyProfiles,
bookingsQuery,
dashboardQuery,
allBookings,

View File

@@ -16,7 +16,7 @@ import {
AlertCircle,
ArrowLeft,
ArrowRight,
UserCheck,
// UserCheck,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -47,7 +47,8 @@ type CompanyStep =
| "additional";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyFirstName: z.string().min(1, "First name is required"),
companyLastName: z.string().min(1, "Last name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
@@ -57,7 +58,10 @@ const onboardingSchema = z.object({
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
tinNumber: z
.string()
.length(10, "TIN must be exactly 10 digits")
.regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"),
vatNumber: z
.string()
.min(1, "VAT number is required")
@@ -75,7 +79,12 @@ const onboardingSchema = z.object({
kebele: z.string().optional(),
houseNo: z.string().optional(),
etradePhone: z.string().optional(),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonFirstName: z
.string()
.min(1, "Contact person first name is required"),
contactPersonLastName: z
.string()
.min(1, "Contact person last name is required"),
contactPersonPosition: z.string().optional(),
contactPersonEmail: z
.string()
@@ -86,13 +95,15 @@ const onboardingSchema = z.object({
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerFirstName: z.string().min(1, "GM first name is required"),
generalManagerLastName: z.string().min(1, "GM last name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaFirstName: z.string().optional(),
poaLastName: z.string().optional(),
poaPhone: z
.string()
.optional()
@@ -106,7 +117,8 @@ type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyFirstName",
"companyLastName",
"companyEmail",
"companyPhone",
"companyLocation",
@@ -128,12 +140,14 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"etradePhone",
],
personnel: [
"generalManagerName",
"generalManagerFirstName",
"generalManagerLastName",
"generalManagerEmail",
"generalManagerPhone",
],
contact: [
"contactPersonName",
"contactPersonFirstName",
"contactPersonLastName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
@@ -143,9 +157,23 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
additional: [],
};
/** Join first + last into the single name the API stores. */
function joinName(first?: string, last?: string): string {
return [first?.trim(), last?.trim()].filter(Boolean).join(" ");
}
/** Split a stored single name into first (first token) + last (the rest). */
function splitName(full?: string | null): { first: string; last: string } {
const trimmed = (full ?? "").trim();
if (!trimmed) return { first: "", last: "" };
const idx = trimmed.indexOf(" ");
if (idx === -1) return { first: trimmed, last: "" };
return { first: trimmed.slice(0, idx), last: trimmed.slice(idx + 1).trim() };
}
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyName: joinName(data.companyFirstName, data.companyLastName),
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
@@ -154,14 +182,20 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonName: joinName(
data.contactPersonFirstName,
data.contactPersonLastName,
),
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerName: joinName(
data.generalManagerFirstName,
data.generalManagerLastName,
),
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaName: joinName(data.poaFirstName, data.poaLastName) || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
@@ -175,7 +209,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
switch (step) {
case "company":
return {
companyName: d.companyName,
companyName: joinName(d.companyFirstName, d.companyLastName),
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
@@ -198,20 +232,26 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
};
case "personnel":
return {
generalManagerName: d.generalManagerName,
generalManagerName: joinName(
d.generalManagerFirstName,
d.generalManagerLastName,
),
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "contact":
return {
contactPersonName: d.contactPersonName,
contactPersonName: joinName(
d.contactPersonFirstName,
d.contactPersonLastName,
),
contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaName: joinName(d.poaFirstName, d.poaLastName) || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
@@ -226,8 +266,13 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
function toFormValues(p: ProfileResponse): FormData {
// The draft placeholder TIN ("D…") shouldn't show as a real value.
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
const companyN = splitName(p.companyName);
const contactN = splitName(p.contactPersonName);
const gmN = splitName(p.generalManagerName);
const poaN = splitName(p.poaName);
return {
companyName: p.companyName ?? "",
companyFirstName: companyN.first,
companyLastName: companyN.last,
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
@@ -247,14 +292,17 @@ function toFormValues(p: ProfileResponse): FormData {
kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "",
etradePhone: p.etradePhone ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonFirstName: contactN.first,
contactPersonLastName: contactN.last,
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerFirstName: gmN.first,
generalManagerLastName: gmN.last,
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaFirstName: poaN.first,
poaLastName: poaN.last,
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
@@ -352,7 +400,8 @@ export default function CompanyProfileForm({
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyName: "",
companyFirstName: "",
companyLastName: "",
companyEmail: "",
companyPhone: "",
companyLocation: "",
@@ -372,14 +421,17 @@ export default function CompanyProfileForm({
kebele: "",
houseNo: "",
etradePhone: "",
contactPersonName: "",
contactPersonFirstName: "",
contactPersonLastName: "",
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "",
generalManagerName: "",
generalManagerFirstName: "",
generalManagerLastName: "",
generalManagerEmail: "",
generalManagerPhone: "",
poaName: "",
poaFirstName: "",
poaLastName: "",
poaPhone: "",
poaAddress: "",
poaEmail: "",
@@ -390,21 +442,24 @@ export default function CompanyProfileForm({
});
// The business owner/manager pulled from eTrade — powers "Use owner as
// manager" on the General Manager step. Null until a TIN lookup succeeds.
// manager" on the General Manager step.
const [etradeOwner, setEtradeOwner] = useState<{
name: string;
phone: string;
email?: string;
} | null>(null);
// Mirror the two "copy from previous person" checkboxes so they can be
// re-toggled (re-checking re-pulls the latest values).
// Mirror the three "copy from previous person" checkboxes.
const [ownerIsGm, setOwnerIsGm] = useState(false);
const [gmIsContact, setGmIsContact] = useState(false);
const [contactIsPoa, setContactIsPoa] = useState(false);
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
// Company name comes from the eTrade manager/owner name on the license.
if (data.managerName) {
setValue("companyName", data.managerName, { shouldValidate: true });
const { first, last } = splitName(data.managerName);
setValue("companyFirstName", first, { shouldValidate: true });
setValue("companyLastName", last, { shouldValidate: true });
}
setValue("licenceNumber", data.licenceNumber);
setValue("statusDescription", data.statusDescription);
@@ -445,13 +500,20 @@ export default function CompanyProfileForm({
phone: toEthiopianE164(
data.managerPhone || data.regularPhone || data.mobilePhone,
),
email: data.managerEmail || undefined,
});
};
/** Fill the General Manager from the eTrade business owner. */
const useOwnerAsManager = () => {
if (!etradeOwner) return;
setValue("generalManagerName", etradeOwner.name);
const toggleOwnerAsGm = (checked: boolean) => {
setOwnerIsGm(checked);
if (!checked || !etradeOwner) return;
const { first, last } = splitName(etradeOwner.name);
setValue("generalManagerFirstName", first, { shouldValidate: true });
setValue("generalManagerLastName", last, { shouldValidate: true });
setValue("generalManagerEmail", etradeOwner.email ?? "", {
shouldValidate: true,
});
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
shouldValidate: true,
});
@@ -461,7 +523,8 @@ export default function CompanyProfileForm({
const toggleGmAsContact = (checked: boolean) => {
setGmIsContact(checked);
if (!checked) return;
setValue("contactPersonName", watch("generalManagerName"));
setValue("contactPersonFirstName", watch("generalManagerFirstName"));
setValue("contactPersonLastName", watch("generalManagerLastName"));
setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone"));
};
@@ -470,7 +533,8 @@ export default function CompanyProfileForm({
const toggleContactAsPoa = (checked: boolean) => {
setContactIsPoa(checked);
if (!checked) return;
setValue("poaName", watch("contactPersonName"));
setValue("poaFirstName", watch("contactPersonFirstName"));
setValue("poaLastName", watch("contactPersonLastName"));
setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone"));
};
@@ -562,15 +626,23 @@ export default function CompanyProfileForm({
<Divider my="sm" />
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Global"
error={errors.companyFirstName?.message}
{...register("companyFirstName")}
/>
<TextInput
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Logistics Ltd"
error={errors.companyLastName?.message}
{...register("companyLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label={<>Company Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
@@ -584,21 +656,21 @@ export default function CompanyProfileForm({
/>
</SimpleGrid>
<TextInput
label="Location"
label={<>Location <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="VAT Number"
label={<>VAT Number <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
<TextInput
label="FAN Number (16 digits)"
label={<>FAN Number (16 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
@@ -611,16 +683,21 @@ export default function CompanyProfileForm({
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Text size="xs" c="edr-muted">
Auto-filled from eTrade these fields cannot be edited.
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="License Number"
placeholder="01/23/01/19786/2006"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.licenceNumber?.message}
{...register("licenceNumber")}
/>
<TextInput
label="Status"
placeholder="Not renewed for 2 years"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.statusDescription?.message}
{...register("statusDescription")}
/>
@@ -628,13 +705,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Date Registered"
placeholder="12/17/2013"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.dateRegistered?.message}
{...register("dateRegistered")}
/>
<TextInput
label="Renewal Date"
placeholder="3/17/2016"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewalDate?.message}
{...register("renewalDate")}
/>
@@ -642,13 +721,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Renewed From"
placeholder="3/17/2016"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedFrom?.message}
{...register("renewedFrom")}
/>
<TextInput
label="Renewed To"
placeholder="7/7/2016"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedTo?.message}
{...register("renewedTo")}
/>
@@ -660,13 +741,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Region"
placeholder="Tigray"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.region?.message}
{...register("region")}
/>
<TextInput
label="Zone"
placeholder="EASTERN TIGRAY"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.zone?.message}
{...register("zone")}
/>
@@ -674,13 +757,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Woreda"
placeholder="EROB"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
placeholder="ARAS"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.kebele?.message}
{...register("kebele")}
/>
@@ -688,7 +773,8 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md">
<TextInput
label="House No"
placeholder="House Number"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.houseNo?.message}
{...register("houseNo")}
/>
@@ -704,31 +790,33 @@ export default function CompanyProfileForm({
{step === "personnel" && (
<>
<Group justify="space-between" align="center">
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{etradeOwner && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useOwnerAsManager}
>
Use owner as manager
</Button>
)}
</Group>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
<Checkbox
color="edr-green"
label="Use eTrade business owner as General Manager"
checked={ownerIsGm}
disabled={!etradeOwner}
onChange={(e) => toggleOwnerAsGm(e.currentTarget.checked)}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Abebe"
error={errors.generalManagerFirstName?.message}
{...register("generalManagerFirstName")}
/>
<TextInput
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Bikila"
error={errors.generalManagerLastName?.message}
{...register("generalManagerLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label={<>Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
@@ -757,19 +845,25 @@ export default function CompanyProfileForm({
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Jane"
error={errors.contactPersonFirstName?.message}
{...register("contactPersonFirstName")}
/>
<TextInput
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Smith"
error={errors.contactPersonLastName?.message}
{...register("contactPersonLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Position (Optional)"
placeholder="Operations Lead"
error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email (Optional)"
type="email"
@@ -777,6 +871,8 @@ export default function CompanyProfileForm({
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<ControlledPhoneField
control={control}
name="contactPersonPhone"
@@ -799,12 +895,20 @@ export default function CompanyProfileForm({
checked={contactIsPoa}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)}
/>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA First Name"
placeholder="First name"
error={errors.poaFirstName?.message}
{...register("poaFirstName")}
/>
<TextInput
label="PoA Last Name"
placeholder="Last name"
error={errors.poaLastName?.message}
{...register("poaLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"

View File

@@ -1,48 +1,39 @@
import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import useAuth from "@/hooks/useAuth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png";
type LoginMethod = "email" | "phone";
const loginMethods: Array<{
value: LoginMethod;
label: string;
icon: typeof Mail;
placeholder: string;
}> = [
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
];
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
function normaliseIdentifier(raw: string): string {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
}
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const currentMethod = loginMethods.find((item) => item.value === method)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setLoading(true);
try {
// In phone mode the identifier is already a canonical E.164 string
// (e.g. +251912345678) from the phone field; email mode passes through.
const result = await login({ email: identifier, password });
const result = await login({ email: normaliseIdentifier(identifier), password });
if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname;
@@ -74,55 +65,19 @@ export default function LoginPage() {
</div>
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={method}
onChange={(event) => {
setMethod(event.target.value as LoginMethod);
setIdentifier("");
}}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
{loginMethods.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span>
Email or Phone <span className="text-red-500">*</span>
</label>
{method === "phone" ? (
<div className="edr-phone-wrapper">
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
placeholder="912 345 678"
disabled={loading}
value={identifier || undefined}
onChange={(v) => setIdentifier(v ?? "")}
/>
</div>
) : (
<input
type="email"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
)}
<input
type="text"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder="name@company.com or 09XXXXXXXX"
disabled={loading}
autoComplete="username"
className={fieldClass}
/>
</div>
<div className="space-y-1.5">

View File

@@ -9,6 +9,7 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
@@ -62,6 +63,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
const isClearance = [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"OPERATION_REQUESTED",
].includes(status);
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
// before approval, so it doesn't linger for the rest of the booking's life.
@@ -126,6 +133,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<ContractCard booking={booking} navigate={navigate} />
{isClearance && <ClearanceCard booking={booking} />}
<BodyGrid
left={
<>

View File

@@ -0,0 +1,377 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { CardTitle, SectionCard } from "./layout";
import { IconSquare } from "./Documents";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
/**
* Customer-facing clearance section: shows the resolved document grid, lets the
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const status = booking.status as string;
const { data: clearance, isLoading } = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
[],
);
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
if (status === "OPERATION_REQUESTED") {
return (
<SectionCard>
<CardTitle>Operation</CardTitle>
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mt="sm">
Operation requested. An operator will take your shipment forward.
</Alert>
</SectionCard>
);
}
if (isLoading || !clearance) {
return (
<SectionCard>
<CardTitle>Clearance documents</CardTitle>
<Text fz="13px" c="dimmed" mt="sm">
Loading clearance
</Text>
</SectionCard>
);
}
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
function handleSubmit() {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files });
}
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{clearance.includesCustoms && (
<Text fz="12px" fw={600} c="#9AA8B5">
Customs clearance
</Text>
)}
</Group>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
Clearance is ready. You can now proceed to operation.
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
Global Logistics is reviewing your documents. Queried documents below
need to be re-uploaded.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
Upload the documents below to start the clearance review.
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) =>
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
{canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={uploadMutation.isPending}
disabled={
Object.keys(pending).length === 0 &&
!adHoc.some((r) => r.file)
}
>
Submit documents
</Button>
)}
{isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
proceedMutation.mutate(
{ id: booking.id },
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
)
}
loading={proceedMutation.isPending}
>
Proceed to operation
</Button>
)}
</Group>
</SectionCard>
);
}

View File

@@ -114,9 +114,18 @@ export function PaymentCard({
booking: Freight.IBooking;
pricing: Pricing;
}) {
const hasItems = priceLineItems(pricing).length > 0;
const paid = booking.paymentStatus === "PAID";
const total = priceTotal(pricing);
// Customer sees the grand total plus the price breakdown that makes it up.
// A staff adjustment, when present, overrides the computed total and is
// flagged with an "Adjusted by EDR" badge.
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const currency = pricing?.currency ?? booking.paymentCurrency;
const total = isAdjusted
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
: priceTotal(pricing);
const hasItems = priceLineItems(pricing).length > 0;
return (
<SectionCard p={22}>
@@ -148,6 +157,28 @@ export function PaymentCard({
<Text fz="26px" fw={800} c="#10202F">
{total}
</Text>
{isAdjusted && (
<Box
component="span"
mt={6}
style={{
display: "inline-block",
borderRadius: 6,
backgroundColor: "#EAF1FB",
padding: "3px 8px",
fontSize: 11,
fontWeight: 700,
color: "#2E5B96",
}}
>
Adjusted by EDR
</Box>
)}
{isAdjusted && booking.adjustmentReason && (
<Text mt={6} fz="12.5px" c="#6B7C8E">
{booking.adjustmentReason}
</Text>
)}
{paid && (
<Text mt={4} fz="12.5px" c="#9AA8B5">
Paid · {fmtDate(booking.updatedAt)}
@@ -165,9 +196,9 @@ export function PaymentCard({
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
Total
{isAdjusted ? "Adjusted total" : "Total"}
</Text>
<Text fz="15px" fw={800} c="#0A6F4D">
<Text fz="15px" fw={800} c="#10202F">
{total}
</Text>
</Group>

View File

@@ -144,6 +144,29 @@ export const STATUS_MAP: Record<
description: "Your shipment is currently moving through the rail network.",
stage: 6,
},
OPERATION_REQUEST_PENDING: {
title: "Operation request under review",
description:
"Your order has been submitted to operations and is awaiting acceptance.",
stage: 4,
},
OPERATION_CHANGES_REQUESTED: {
title: "Operation changes requested",
description: "Operations requested changes to this order. Please review and resubmit.",
stage: 4,
},
OPERATION_PRICE_PENDING_CONFIRM: {
title: "Price adjusted — confirm to proceed",
description:
"Operations adjusted this order's price. Confirm the new price to proceed.",
stage: 4,
},
ROAD_DISPATCH_PENDING: {
title: "Awaiting truck dispatch",
description:
"This road order was accepted and is awaiting truck dispatch. Billed by distance.",
stage: 5,
},
PENDING_CONSOLIDATION: {
title: "Pending consolidation",
description: "Awaiting a consolidation partner shipment.",

View File

@@ -53,6 +53,7 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { LocationPicker } from "./new-booking-form/LocationPicker";
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
@@ -113,6 +114,12 @@ function mapBookingToFormValues(
): BookingFormInputValues {
const vals = {
...initialBookingFormValues,
operationType:
booking.tradeDirection === "IMPORT"
? "import"
: booking.tradeDirection === "EXPORT"
? "export"
: "intercity",
contractType:
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
previousContractRef: booking.previousContractId ?? "",
@@ -121,11 +128,17 @@ function mapBookingToFormValues(
firstMile: {
enabled: booking.firstMileEnabled ?? false,
pickUpAddress: booking.firstMilePickupAddress ?? "",
lat: booking.firstMilePickupLat ?? null,
lng: booking.firstMilePickupLng ?? null,
},
lastMile: {
enabled: booking.lastMileEnabled ?? false,
deliveryAddress: booking.lastMileDeliveryAddress ?? "",
lat: booking.lastMileDeliveryLat ?? null,
lng: booking.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: booking.customsClearingEnabled ?? false,
customsClearingAgent: booking.customsClearingAgent ?? "",
equipmentReturn:
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
@@ -136,8 +149,6 @@ function mapBookingToFormValues(
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.id ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
paymentCurrency:
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
scheduledDate: booking.scheduledDate
@@ -333,12 +344,19 @@ export default function EditBookingPage() {
const serviceTypeId = form.watch("serviceTypeId");
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const selectedService = useMemo(
() => referenceData?.service.find((s) => s.id === serviceTypeId),
[serviceTypeId, referenceData],
);
const showFirstMile = Boolean(
selectedService?.includesFirstMile && firstMileEnabled,
);
const showLastMile = Boolean(
selectedService?.includesLastMile && lastMileEnabled,
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
@@ -357,20 +375,6 @@ export default function EditBookingPage() {
}));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
// Dedupe by name (the value the form keys on) so two lines sharing a name
// can't produce a duplicate Select option and crash Mantine.
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {
const current = (form.getValues("documents") ?? {}) as BookingDocuments;
form.setValue(
@@ -432,7 +436,6 @@ export default function EditBookingPage() {
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled,
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -452,14 +455,25 @@ export default function EditBookingPage() {
? { pnrCode: data.previousContractRef }
: {}),
...(selectedSvc?.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
? {
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
: {}),
...(selectedSvc?.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: data.shippingLine }
? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
...(selectedSvc?.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent,
}
: { customsClearingEnabled: false }),
};
updateMutation.mutate(apiPayload);
@@ -630,27 +644,19 @@ export default function EditBookingPage() {
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: addr, fieldState }) => (
<TextInput
{...addr}
mt="sm"
radius="md"
placeholder="Pick-up address *"
error={fieldState.error?.message}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Set the exact pick-up location on the map in the Route
tab.
</Text>
)}
</ToggleRow>
)}
@@ -670,27 +676,19 @@ export default function EditBookingPage() {
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: addr, fieldState }) => (
<TextInput
{...addr}
mt="sm"
radius="md"
placeholder="Delivery address *"
error={fieldState.error?.message}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Set the exact delivery location on the map in the Route
tab.
</Text>
)}
</ToggleRow>
)}
@@ -707,8 +705,32 @@ export default function EditBookingPage() {
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={field.onChange}
/>
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: agent, fieldState }) => (
<TextInput
{...agent}
mt="sm"
radius="md"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
/>
)}
/>
)}
</ToggleRow>
)}
/>
</Paper>
@@ -765,20 +787,73 @@ export default function EditBookingPage() {
</Alert>
)}
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
data={shippingLineOptions}
{(showFirstMile || showLastMile) && (
<Stack gap="md">
<SectionHeading
title="Trucking locations"
description="Search an address or click the map to drop a pin for your door-to-port and port-to-door trucking."
/>
{showFirstMile && (
<Controller
name="firstMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="First mile — pick-up location"
placeholder="Search the pick-up address…"
error={
(fieldState.error as { pickUpAddress?: { message?: string } })
?.pickUpAddress?.message
}
value={{
address: field.value?.pickUpAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
/>
{showLastMile && (
<Controller
name="lastMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="Last mile — delivery location"
placeholder="Search the delivery address…"
error={
(fieldState.error as { deliveryAddress?: { message?: string } })
?.deliveryAddress?.message
}
value={{
address: field.value?.deliveryAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
</Stack>
)}
<Paper withBorder radius="md">
@@ -817,7 +892,6 @@ export default function EditBookingPage() {
<Box>
<Step5CargoDetails
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={!referenceData}
/>

View File

@@ -34,7 +34,8 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { ModeIndicator } from "@/components/ModeIndicator";
import useAuth from "@/hooks/useAuth";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import {
BookingTypeBadge,
CargoModeCell,
@@ -256,10 +257,13 @@ function fmtDate(iso?: string | null): string {
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
function useStatusCount(
statuses: string | undefined,
companyProfileId?: string,
): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
input: { statuses, companyProfileId, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
@@ -335,8 +339,18 @@ export default function MyBookings() {
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
// Operational-service options (importer / exporter / freight forwarder) for
// the per-page filter. Empty for non-customer companies.
const { company } = useAuth();
const companyProfiles = company?.company?.companyProfiles ?? [];
const serviceOptions = companyProfiles.map((p) => ({
value: p.id,
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
@@ -352,10 +366,15 @@ export default function MyBookings() {
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
!!typeFilter ||
!!freightFilter ||
!!serviceFilter ||
!!createdFrom ||
!!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setServiceFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
@@ -366,6 +385,7 @@ export default function MyBookings() {
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
companyProfileId: serviceFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
@@ -376,6 +396,7 @@ export default function MyBookings() {
statuses,
typeFilter,
freightFilter,
serviceFilter,
createdFrom,
createdTo,
pagination.pageIndex,
@@ -387,25 +408,33 @@ export default function MyBookings() {
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
// Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
// selected service so the cards match the filtered table.
const svc = serviceFilter ?? undefined;
const allCount = useStatusCount(undefined, svc);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
svc,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
svc,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
svc,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
svc,
);
const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
svc,
);
const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
svc,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
@@ -617,7 +646,6 @@ export default function MyBookings() {
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
@@ -723,6 +751,22 @@ export default function MyBookings() {
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
{serviceOptions.length > 1 && (
<Select
placeholder="All services"
data={serviceOptions}
value={serviceFilter}
onChange={(v) => {
setServiceFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by service"
/>
)}
<TextInput
type="date"
value={createdFrom}

View File

@@ -1,6 +1,5 @@
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
@@ -24,6 +23,7 @@ import {
Check,
ChevronLeft,
ChevronRight,
Link2,
Send,
XCircle,
} from "lucide-react";
@@ -34,15 +34,19 @@ import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
STEPS,
allowedOperationsForProfiles,
bookingFormSchema,
getRouteDirection,
initialBookingFormValues,
operationToProfileType,
stepFields,
type BookingDocuments,
type BookingFormValues,
type OperationType,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
import {
Step0OperationType,
Step1ContractType,
Step2ServiceType,
Step4Route,
@@ -57,7 +61,7 @@ type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [step, setStep] = useState(0);
const auth = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(),
@@ -97,6 +101,40 @@ export default function NewBookingPage() {
);
}
if (!auth.isPending && auth.companyStatus === "pending") {
return (
<Box
style={{
padding: "28px",
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<Alert
color="orange"
icon={<AlertCircle size={20} />}
radius="md"
style={{ maxWidth: "500px" }}
mb="lg"
>
<Text size="lg" fw={600} mb="md">
Awaiting Approval
</Text>
<Text size="sm" mb="md">
Your company is awaiting EDR approval. Creating bookings is disabled
until your company has been approved.
</Text>
<Button color="orange" onClick={() => navigate("/bookings")} mt="md">
Back to Bookings
</Button>
</Alert>
</Box>
);
}
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
@@ -140,6 +178,12 @@ export default function NewBookingPage() {
}
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
// A partial-wagon booking is parked until a partner is found — explain the
// wait in a modal before sending the customer to the detail page.
if (result.status === "PENDING_CONSOLIDATION") {
setConsolidationPending(true);
return;
}
navigate(`/bookings/${priceBookingId}`);
},
});
@@ -149,14 +193,32 @@ export default function NewBookingPage() {
if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
onSuccess: (result) => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
if (result.status === "PENDING_CONSOLIDATION") {
setConsolidationPending(true);
return;
}
navigate(`/bookings/${priceBookingId}`);
},
});
// Customer rejects the priced booking → it becomes REJECTED (terminal) and the
// customer starts a fresh booking.
const rejectMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to reject");
return api.bookings.reject.call({ id: priceBookingId });
},
onSuccess: () => {
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate("/bookings");
},
});
const abortMutation = useMutation({
mutationFn: async (reason: string) => {
if (!priceBookingId) throw new Error("No booking to abort");
@@ -211,6 +273,39 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
// The company's onboarded profile types — drives which operations are offered
// and which profile each operation stamps the booking to.
const profileTypes = useMemo(
() => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type),
[auth.company],
);
// Operations the customer may book, gated by the company's onboarded profiles.
const allowedOperations = useMemo<OperationType[]>(
() => allowedOperationsForProfiles(profileTypes),
[profileTypes],
);
// Stamp the booking to the right operational profile. Import/Export (and their
// "as FF" variants) switch the active mode so the matching onboarding documents
// are attached; Intercity uses whatever profile is already active.
const handleOperationSelect = (op: OperationType) => {
if (op === "intercity") return;
const target = operationToProfileType(op, profileTypes);
if (auth.activeProfileType !== target) {
void auth.switchMode(target as never);
}
};
// Onboarding documents for the active profile — shown read-only in the
// Documents step and attached to the booking on submit by the backend.
const onboardingDocs = useMemo(() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
return active?.licenseFiles ?? [];
}, [auth.company, auth.activeCompanyProfileId]);
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null,
);
@@ -222,19 +317,15 @@ export default function NewBookingPage() {
useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
// Shown when a submitted booking is parked waiting for a consolidation partner.
const [consolidationPending, setConsolidationPending] = useState(false);
async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
return;
}
// Documents (step 6) is read-only — the active profile's onboarding files are
// attached automatically, so there is nothing to validate here.
goToStep(1);
}
@@ -248,14 +339,6 @@ export default function NewBookingPage() {
throw new Error("Validation failed");
}
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
0,
)
: Number(data.cargoWeight || 0);
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
@@ -274,6 +357,28 @@ export default function NewBookingPage() {
.flatMap((g) => g.children ?? [])
.find((c) => c.id === childId);
// Bulk amount lives in cargoTotalWeightVgm — tons (estimated) or a whole
// item count, depending on the commodity's unit_of_measure. Item counts are
// rounded since fractional items are meaningless. Containers carry NO weight
// at the wizard — VGM is captured later in operations — so container bookings
// send 0.
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const isContract = data.bookingType === "general_contract";
// For bulk general contracts the contracted quantity is entered against the
// primary route in the route step; one-time bookings use the cargo-step
// amount. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw =
isContract && data.cargoType === "bulk"
? data.primaryRouteQuantity
: data.cargoWeight;
const totalWeight =
data.cargoType === "container"
? 0
: isPerItem
? Math.round(Number(bulkAmountRaw || 0))
: Number(bulkAmountRaw || 0);
const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
const cargoFreeText = bulkChild?.show_free_text_box
@@ -284,8 +389,6 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
const isContract = data.bookingType === "general_contract";
return {
bookingType: isContract
? Freight.BookingType.GeneralContract
@@ -314,7 +417,6 @@ export default function NewBookingPage() {
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -324,7 +426,8 @@ export default function NewBookingPage() {
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
vgmPerUnitTons: Number(c.vgm || 0),
// Weight (VGM) is not collected at the wizard — captured in operations.
vgmPerUnitTons: 0,
}))
: [],
...(data.previousContractRef
@@ -334,15 +437,60 @@ export default function NewBookingPage() {
? { pnrCode: data.previousContractRef }
: {}),
...(serviceType.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
? {
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
: {}),
...(serviceType.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: data.shippingLine }
? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
// Customs clearing: only when the service offers it and the customer opted
// in; the agent name is required by the form in that case.
...(serviceType.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent,
}
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: route #1 is the primary origin/destination
// carrying the full contracted quantity; each extra route reserves its own.
...(isContract
? {
routes: [
{
originYardId: data.originYard,
destinationYardId: data.destinationYard,
quantity:
data.cargoType === "container"
? data.containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
)
: totalWeight,
},
...(data.extraRoutes ?? [])
.filter(
(r) =>
r.originYard &&
r.destinationYard &&
Number(r.quantity) > 0,
)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
quantity: Number(r.quantity),
...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
})),
],
}
: {}),
};
}
@@ -360,14 +508,8 @@ export default function NewBookingPage() {
});
const handleSubmitBooking = form.handleSubmit((data) => {
if (!hasAllRequiredDocuments(data.documents)) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
setStep(6);
return;
}
// Documents are reused from onboarding and attached by the backend, so there
// is no upload requirement to enforce here.
try {
const apiPayload = buildApiPayload(data);
persistAndPriceMutation.mutate({
@@ -464,6 +606,13 @@ export default function NewBookingPage() {
</Alert>
)}
{step === 0 && (
<Step0OperationType
form={form}
allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
/>
)}
{step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
@@ -471,16 +620,15 @@ export default function NewBookingPage() {
<Step2ServiceType referenceData={referenceData} form={form} />
)}
{step === 3 && (
<Step4Route
<Step5CargoDetails
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
<Step4Route
form={form}
direction={direction!}
referenceData={referenceData}
isLoading={refDataLoading}
/>
@@ -488,13 +636,14 @@ export default function NewBookingPage() {
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments form={form} />}
{step === 6 && <StepDocuments documents={onboardingDocs} />}
{step === 7 && (
<Step8Review
form={form}
setStep={setStep}
direction={direction!}
referenceData={referenceData}
onboardingDocs={onboardingDocs}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
@@ -579,29 +728,28 @@ export default function NewBookingPage() {
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
: "Your booking has been saved as a draft. Here is the estimated price."}
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
: "Your booking has been saved as a draft. Here is your estimated total price."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
<Box
p="lg"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background:
"linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
}}
>
<Text size="xs" fw={700} tt="uppercase" c="edr-green" style={{ letterSpacing: "0.06em" }}>
Total price
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
<Text fw={800} fz={30} c="#10202F" mt={4}>
{pricingData.totalAmount.toLocaleString()}{" "}
<Text span fz={18} fw={700} c="edr-muted">
{pricingData.currency}
</Text>
</Text>
</Group>
</Box>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
@@ -611,12 +759,15 @@ export default function NewBookingPage() {
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
variant="outline"
color="red"
radius="md"
onClick={closePriceModal}
leftSection={<XCircle size={16} />}
onClick={() => rejectMutation.mutate()}
loading={rejectMutation.isPending}
disabled={confirmMutation.isPending}
>
Cancel
Reject
</Button>
<Button
color="edr-green"
@@ -624,6 +775,7 @@ export default function NewBookingPage() {
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Confirm & submit
</Button>
@@ -690,6 +842,66 @@ export default function NewBookingPage() {
)}
</Modal>
<Modal
opened={consolidationPending}
onClose={() => {
setConsolidationPending(false);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}}
title={
<Group gap={10} wrap="nowrap">
<div
className="flex shrink-0 items-center justify-center rounded-[11px] border border-[#F4D9A8]"
style={{ width: 38, height: 38, backgroundColor: "#FDF3E0", color: "#C77F12" }}
>
<Link2 size={20} />
</div>
<Text fw={700}>Waiting to share a wagon</Text>
</Group>
}
radius="lg"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Your booking is submitted, but your cargo only fills part of a wagon.
We&apos;re pairing it with another shipment on the same route to share
the space.
</Text>
<Box
p="md"
style={{
borderRadius: 12,
border: "1px solid #F4D9A8",
background: "#FDF3E0",
}}
>
<Text size="sm" fw={600} c="#10202F">
What happens next
</Text>
<Text size="sm" mt={4} c="#7A6A4E">
As soon as a matching shipment is found, your booking continues
automatically you&apos;ll be notified, and no action is needed
from you until then. Your acceptance, approval and contract stay
independent and yours alone.
</Text>
</Box>
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
radius="md"
onClick={() => {
setConsolidationPending(false);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -0,0 +1,336 @@
import "leaflet/dist/leaflet.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Combobox, InputBase, Loader, Text, useCombobox } from "@mantine/core";
import { MapPin, Search } from "lucide-react";
import L from "leaflet";
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
import { fieldStyles } from "./shared";
/** A resolved place: a human address plus its coordinates. */
export interface LocationValue {
address: string;
lat: number | null;
lng: number | null;
}
/** A single Nominatim search result, normalised to what the UI needs. */
interface GeocodeResult {
displayName: string;
lat: number;
lng: number;
}
// Leaflet's default marker icon URLs break under bundlers; point them at the
// CDN-hosted assets once so every map instance renders a visible pin.
const markerIcon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
const DEFAULT_CENTER: [number, number] = [9.03, 38.74];
const DEFAULT_ZOOM = 6;
const PINNED_ZOOM = 14;
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
// Search only fires once the user pauses typing for this long. Slightly longer
// than a keystroke burst so we make one request per pause, not per character —
// and it keeps us within Nominatim's 1 req/s fair-use limit.
const SEARCH_DEBOUNCE_MS = 450;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
// first (Nominatim still returns global matches if nothing local fits).
const SEARCH_COUNTRYCODES = "et,dj";
/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
async function nominatimSearch(
query: string,
signal: AbortSignal,
countryCodes?: string,
): Promise<GeocodeResult[]> {
const params = new URLSearchParams({
q: query,
format: "jsonv2",
addressdetails: "0",
limit: "8",
});
if (countryCodes) params.set("countrycodes", countryCodes);
const res = await fetch(`${NOMINATIM_URL}?${params}`, {
signal,
headers: { Accept: "application/json", "Accept-Language": "en" },
});
if (!res.ok) return [];
const data = (await res.json()) as Array<{
display_name: string;
lat: string;
lon: string;
}>;
return data.map((d) => ({
displayName: d.display_name,
lat: Number(d.lat),
lng: Number(d.lon),
}));
}
/**
* Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
* local addresses rank highest, then fall back to a global search when nothing
* local matches — so the field never looks "broken" for an out-of-region query.
*/
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
if (local.length > 0) return local;
return nominatimSearch(query, signal);
}
/** Reverse-geocode a dropped pin to its nearest address. */
async function reverseGeocode(lat: number, lng: number): Promise<string> {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lng),
format: "json",
});
try {
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return "";
const data = (await res.json()) as { display_name?: string };
return data.display_name ?? "";
} catch {
return "";
}
}
/**
* Leaflet computes its tile layout from the container size at mount. When the
* map is revealed inside a just-toggled section it can mount before layout
* settles and render grey tiles — invalidating the size on the next frame
* forces a correct redraw.
*/
function InvalidateSizeOnMount() {
const map = useMap();
useEffect(() => {
const id = setTimeout(() => map.invalidateSize(), 0);
return () => clearTimeout(id);
}, [map]);
return null;
}
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
useEffect(() => {
if (lat != null && lng != null) {
map.setView([lat, lng], PINNED_ZOOM, { animate: true });
}
}, [lat, lng, map]);
return null;
}
/** Captures map clicks and forwards the dropped coordinate. */
function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => onPick(e.latlng.lat, e.latlng.lng),
});
return null;
}
export interface LocationPickerProps {
value: LocationValue;
onChange: (value: LocationValue) => void;
label: string;
placeholder?: string;
error?: string;
}
/**
* Address + map location picker backed by free OpenStreetMap services:
* - type to search (Nominatim forward geocoding),
* - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker({
value,
onChange,
label,
placeholder = "Search an address or click the map…",
error,
}: LocationPickerProps) {
const combobox = useCombobox();
const [query, setQuery] = useState("");
const [results, setResults] = useState<GeocodeResult[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const hasPin = value.lat != null && value.lng != null;
// Debounced forward search — fires only after the user stops typing
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
// than one per keystroke. The dropdown is kept open the whole time so the
// user sees the "Searching…" state and then the live results for what they
// typed.
useEffect(() => {
const q = query.trim();
if (q.length < MIN_QUERY_LEN) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
combobox.openDropdown();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const handle = setTimeout(async () => {
try {
const found = await searchPlaces(q, controller.signal);
if (controller.signal.aborted) return;
setResults(found);
combobox.openDropdown();
} catch (err) {
// Ignore aborts (a newer keystroke superseded this request).
if ((err as Error)?.name !== "AbortError") setResults([]);
} finally {
if (!controller.signal.aborted) setSearching(false);
}
}, SEARCH_DEBOUNCE_MS);
// Cancel both the pending debounce AND any in-flight request when the query
// changes, so a stale response can't overwrite newer results.
return () => {
clearTimeout(handle);
controller.abort();
};
}, [query, combobox]);
const selectResult = useCallback(
(r: GeocodeResult) => {
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
setQuery("");
setResults([]);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handlePin = useCallback(
async (lat: number, lng: number) => {
// Show the pin immediately; fill the address once reverse geocoding lands.
onChange({ address: value.address, lat, lng });
setResolving(true);
const address = await reverseGeocode(lat, lng);
setResolving(false);
onChange({
address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
lat,
lng,
});
},
[onChange, value.address],
);
const inputValue = query || value.address;
const center = useMemo<[number, number]>(
() => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER),
[hasPin, value.lat, value.lng],
);
return (
<Box>
<Combobox store={combobox} withinPortal shadow="md" radius="md">
<Combobox.Target>
<InputBase
label={label}
placeholder={placeholder}
value={inputValue}
error={error}
radius={10}
styles={fieldStyles}
leftSection={<Search size={16} />}
rightSection={searching || resolving ? <Loader size={14} /> : null}
onChange={(e) => {
setQuery(e.currentTarget.value);
combobox.openDropdown();
}}
onFocus={() => {
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
}}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
{searching ? (
<Combobox.Empty>Searching {query.trim()}</Combobox.Empty>
) : results.length === 0 ? (
<Combobox.Empty>
{query.trim().length < MIN_QUERY_LEN
? `Type at least ${MIN_QUERY_LEN} characters`
: "No matching places"}
</Combobox.Empty>
) : (
results.map((r, i) => (
<Combobox.Option
key={`${r.lat}-${r.lng}-${i}`}
value={String(i)}
onClick={() => selectResult(r)}
>
<Text fz={13} lineClamp={2}>
{r.displayName}
</Text>
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Box
mt={10}
style={{
height: 260,
borderRadius: 12,
overflow: "hidden",
border: "1px solid #E6ECF2",
}}
>
<MapContainer
center={center}
zoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<InvalidateSizeOnMount />
<ClickToPin onPick={handlePin} />
<MapRecenter lat={value.lat} lng={value.lng} />
{hasPin && (
<Marker
position={[value.lat as number, value.lng as number]}
icon={markerIcon}
/>
)}
</MapContainer>
</Box>
<Text fz={11.5} c="#6B7C8E" mt={6} style={{ display: "flex", gap: 5 }}>
<MapPin size={13} style={{ flexShrink: 0, marginTop: 1 }} />
{hasPin
? value.address || "Pinned location"
: "Search above or click the map to drop a pin."}
</Text>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import { Box, Text } from "@mantine/core";
import { Banknote, DollarSign } from "lucide-react";
import { Box, Group, Text } from "@mantine/core";
import { Banknote, Check, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
@@ -7,14 +7,14 @@ import {
type BookingFormValues,
type PaymentCurrency,
} from "./schema";
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
import { OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
PaymentCurrency,
{ icon: typeof DollarSign; bg: string; color: string }
{ icon: typeof DollarSign; color: string }
> = {
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, color: "#0A6F4D" },
};
export function PaymentCurrencyField({
@@ -33,23 +33,75 @@ export function PaymentCurrencyField({
control={control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
{/* Compact segmented pill selector — lighter than full option cards. */}
<Group
gap={6}
wrap="nowrap"
p={4}
style={{
borderRadius: 12,
background: "#F1F4F7",
border: "1px solid #E6ECF2",
}}
>
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
const selected = field.value === option.value;
return (
<OptionCard
<button
key={option.value}
selected={field.value === option.value}
type="button"
onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />}
iconBg={CURRENCY_ICONS[option.value].bg}
iconColor={CURRENCY_ICONS[option.value].color}
title={option.label}
description={option.description}
/>
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 14px",
borderRadius: 9,
cursor: "pointer",
border: "none",
background: selected ? "#fff" : "transparent",
boxShadow: selected
? "0 1px 3px rgba(16,32,47,0.10)"
: "none",
transition: "all 150ms ease",
}}
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: selected
? CURRENCY_ICONS[option.value].color
: "#94A3B8",
}}
>
<Icon className="h-4 w-4" />
</Box>
<Text
fz={14}
fw={selected ? 700 : 600}
c={selected ? "#10202F" : "#64748B"}
>
{option.label}
</Text>
{selected && (
<Check size={15} color={CURRENCY_ICONS[option.value].color} />
)}
</button>
);
})}
</div>
</Group>
{/* Description for the active currency, kept subtle. */}
<Text fz={11.5} c="#6B7C8E" mt={8}>
{
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
?.description
}
</Text>
<OptionFieldError error={fieldState.error} />
</div>
)}

View File

@@ -3,15 +3,28 @@ import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
export const STEPS = [
{ id: 0, label: "Operation Type", short: "Operation" },
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Shipment Date", short: "Schedule" },
{ id: 3, label: "Cargo Details", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Estimated Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
] as const;
export const OPERATION_TYPES = [
"import",
"export",
"intercity",
// Freight-forwarder variants: same trade direction as import/export but the
// booking is stamped to the company's freight_forwarder profile instead of a
// direct importer/exporter profile.
"import_ff",
"export_ff",
] as const;
export type OperationType = (typeof OPERATION_TYPES)[number];
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
@@ -86,6 +99,10 @@ export type BookingTypeOption = (typeof BOOKING_TYPES)[number];
export const bookingFormSchema = z
.object({
// Operation the booking is for, gated by the company's onboarded profiles.
// Drives trade direction (import/export → IMPORT/EXPORT; intercity → DOMESTIC)
// and the active company profile the booking is stamped to.
operationType: z.enum(OPERATION_TYPES, "Select an operation type."),
// One-time booking vs. a general contract (umbrella, drawn down by orders).
bookingType: z.enum(BOOKING_TYPES).default("one_time"),
contractType: z.enum(["new", "renewal"], "Select a contract type."),
@@ -97,27 +114,53 @@ export const bookingFormSchema = z
.object({
enabled: z.boolean().default(false),
pickUpAddress: z.string(),
// Coordinates resolved by the map picker (geocode/search or pin drop).
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
.refine((data) => !(data.enabled && !data.pickUpAddress.trim()), {
message: "Enter the pick-up address.",
message: "Select the pick-up location on the map.",
path: ["pickUpAddress"],
}),
lastMile: z
.object({
enabled: z.boolean().default(false),
deliveryAddress: z.string(),
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
.refine((data) => !(data.enabled && !data.deliveryAddress.trim()), {
message: "Enter the delivery address.",
message: "Select the delivery location on the map.",
path: ["deliveryAddress"],
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
.default("with_return"),
customsClearingEnabled: z.boolean().default(false),
// Required only when customs clearing is enabled (validated in superRefine).
customsClearingAgent: z.string().default(""),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
// Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
// of the selected commodity (items vs tons). Customers enter it explicitly in
// the route step so the primary route reads consistently with the extra
// routes below. Ignored for one-time bookings; for containers the value is
// derived from the container count instead (see buildApiPayload).
primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination
// above is route #1). Each adds another (origin, destination, quantity) pool.
// Ignored for one-time bookings.
extraRoutes: z
.array(
z.object({
originYard: z.string(),
destinationYard: z.string(),
quantity: z.string(),
// Road distance for this route; used to bill road (truck) orders.
km: z.string().default(""),
}),
)
.default([]),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
// Optional in the base schema — required for one-time bookings via the
@@ -138,17 +181,14 @@ export const bookingFormSchema = z
.refine((q) => q.length !== 0, "Quantity is required.")
.refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
vgm: z
.string()
.refine((vgm) => vgm.length !== 0, "VGM is required.")
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
// Weight (VGM) is NOT collected at the wizard — it is captured later in
// operations. Kept optional so existing payload code stays valid.
vgm: z.string().default("0"),
}),
),
// Consolidation is system-managed, not a customer choice. The backend only
// consolidates partial-wagon bookings, so this is always allowed; the
// customer neither sees nor toggles it.
consolidationEnabled: z.boolean().default(true),
// Consolidation is system-managed, not a customer choice: the backend
// consolidates partial-wagon container bookings automatically, derived from
// the container quantities. The customer neither sees nor toggles it.
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
})
@@ -182,13 +222,15 @@ export const bookingFormSchema = z
)
.refine(
(data) => {
// General contracts capture bulk quantity per route (primaryRouteQuantity),
// not via the cargo-step cargoWeight — so only validate it for one-time
// bulk bookings.
if (data.cargoType !== "bulk") return true;
const cargoWeight = Number(data.cargoWeight);
return (
!!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0
);
if (data.bookingType === "general_contract") return true;
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
{ message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] },
{ message: "Enter a quantity greater than 0.", path: ["cargoWeight"] },
)
.refine(
(data) => !(data.cargoType === "container" && data.containers.length === 0),
@@ -203,6 +245,14 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
// Customs clearing agent is required once the customs service is enabled.
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
ctx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter the customs clearing agent.",
});
}
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -212,6 +262,19 @@ export const bookingFormSchema = z
});
}
}
// General contracts reserve quantity per route. The primary route's quantity
// is entered in the route step; containers derive it from the container
// count, so only bulk cargo requires it here.
if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
const qty = Number(data.primaryRouteQuantity);
if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
ctx.addIssue({
code: "custom",
path: ["primaryRouteQuantity"],
message: "Enter a quantity greater than 0.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -221,14 +284,6 @@ export const bookingFormSchema = z
message: "Enter at least 1 container.",
});
}
if (!c.vgm || +c.vgm <= 0) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "vgm"],
message: "Enter VGM greater than 0.",
});
}
});
}
});
@@ -245,46 +300,56 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
firstMile: {
enabled: false,
pickUpAddress: "",
lat: null,
lng: null,
},
lastMile: {
enabled: false,
deliveryAddress: "",
lat: null,
lng: null,
},
equipmentReturn: "with_return",
customsClearingEnabled: false,
customsClearingAgent: "",
originYard: "",
destinationYard: "",
shippingLine: "",
primaryRouteQuantity: "",
extraRoutes: [],
scheduledDate: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: true,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "0" }],
documents: {},
notes: "",
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
0: ["operationType"],
1: ["bookingType", "contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
"customsClearingEnabled",
"customsClearingAgent",
// First/last-mile pickup & delivery locations are captured inline in the
// service step, right under each trucking toggle.
"firstMile",
"lastMile",
],
3: [
3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
4: [
"originYard",
"destinationYard",
"primaryRouteQuantity",
"extraRoutes",
"isHazardous",
"isRefrigerated",
"shippingLine",
],
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
@@ -294,7 +359,9 @@ export interface ContainerConfig {
type: "20ft" | "40ft";
containerType: string;
qty: string;
vgm: string;
// Optional: VGM is captured later in operations, not at the wizard, and the
// form schema defaults it — so the watched input shape has it as optional.
vgm?: string;
}
export interface WagonConfig {
@@ -330,6 +397,98 @@ export function getRouteDirection(
return "DOMESTIC";
}
/**
* Operations a company may book, derived from its onboarded profile types.
*
* - pure freight forwarder → import, export, intercity
* (these run as FF: the booking is stamped to the freight_forwarder profile)
* - importer → import, intercity
* - exporter → export, intercity
* - importer + exporter → import, export, intercity
* - importer (+/- exporter) + FF → direct import/export PLUS the matching
* "as FF" variants, so the company can book either directly or as a forwarder
*
* Intercity (DOMESTIC) is always available to any customer-side profile.
*/
export function allowedOperationsForProfiles(
profileTypes: string[],
): OperationType[] {
const has = (t: string) => profileTypes.includes(t);
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
const isImporter = has("importer");
const isExporter = has("exporter");
const isDirect = isImporter || isExporter;
const ops = new Set<OperationType>();
// Direct importer/exporter capabilities.
if (isImporter) ops.add("import");
if (isExporter) ops.add("export");
if (isForwarder) {
if (isDirect) {
// Mixed: keep the direct options above and add explicit "as FF" variants
// so the customer can disambiguate which profile the booking belongs to.
ops.add("import_ff");
ops.add("export_ff");
} else {
// Pure forwarder: shows plain Import/Export/Intercity, but these run on the
// freight_forwarder profile (see operationToProfileType).
ops.add("import");
ops.add("export");
}
}
// Any customer-side profile can also run domestic (intercity).
if (isForwarder || isDirect) ops.add("intercity");
// Preserve a stable display order.
return OPERATION_TYPES.filter((o) => ops.has(o));
}
/**
* Whether this operation runs on the freight_forwarder profile. True for the
* explicit FF variants, and for plain import/export when the company is a pure
* forwarder (no direct importer/exporter profile).
*/
export function isForwarderOperation(
op: OperationType,
profileTypes: string[],
): boolean {
if (op === "import_ff" || op === "export_ff") return true;
const has = (t: string) => profileTypes.includes(t);
const isForwarder = has("freight_forwarder") || has("dj_freight_forwarder");
const isDirect = has("importer") || has("exporter");
if ((op === "import" || op === "export") && isForwarder && !isDirect) {
return true;
}
return false;
}
/** Trade direction the backend will derive for a given operation type. */
export function operationToTradeDirection(
op: OperationType,
): Freight.ScheduleTradeDirection {
if (op === "import" || op === "import_ff") return "IMPORT";
if (op === "export" || op === "export_ff") return "EXPORT";
return "DOMESTIC";
}
/**
* The company_profile type a booking for this operation should be stamped to.
* FF variants (and a pure forwarder's plain import/export) → freight_forwarder.
*/
export function operationToProfileType(
op: OperationType,
profileTypes: string[] = [],
): string {
if (op === "import_ff" || op === "export_ff") return "freight_forwarder";
if (isForwarderOperation(op, profileTypes)) return "freight_forwarder";
if (op === "import") return "importer";
if (op === "export") return "exporter";
return "freight_forwarder";
}
export function calcWagons(containers: ContainerConfig[]) {
const Ft20Wagons = containers
.filter((c) => c.type === "20ft")

View File

@@ -1,40 +1,36 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, FileText, FileUp } from "lucide-react";
import {
BOOKING_DOCS_SETTING,
BookingFormInputValues,
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
function countAttached(documents: BookingDocuments): number {
return BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
export interface OnboardingDoc {
name: string;
url: string;
size: number;
mimeType?: string;
}
export function StepDocuments({ form }: { form: BookingForm }) {
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const attached = countAttached(documents);
const total = BOOKING_DOCS_SETTING.fields.length;
function formatSize(bytes: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Read-only documents step: lists the documents the company uploaded during
* onboarding for the active operational profile. These are attached to the
* booking automatically at submission — the customer is never asked to re-upload.
*/
export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
const total = documents.length;
return (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Shipment Documents"
description="Attach your shipment documents now, or skip and upload them later from the booking page."
title="Documents"
description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed."
/>
<Group
@@ -57,36 +53,76 @@ export function StepDocuments({ form }: { form: BookingForm }) {
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
backgroundColor: attached === total ? "#ECF6F1" : "#EAF1FB",
color: attached === total ? "#0A6F4D" : "#2E5B96",
backgroundColor: total > 0 ? "#ECF6F1" : "#FBECEC",
color: total > 0 ? "#0A6F4D" : "#B42318",
}}
>
{attached === total ? (
<CheckCircle2 size={16} />
) : (
`${attached}/${total}`
)}
{total > 0 ? <CheckCircle2 size={16} /> : <FileUp size={16} />}
</Box>
<Text size="sm" c="dimmed">
{attached === 0
? "All documents are optional here — you can upload them later from the booking page."
: `${attached} of ${total} attached. You can finish the rest later from the booking page.`}
{total > 0
? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.`
: "No onboarding documents found on your active profile. You can add documents later from the booking page."}
</Text>
</Group>
<Controller
name="documents"
control={form.control}
render={({ field }) => (
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={(field.value ?? {}) as BookingDocuments}
onChange={(value) => field.onChange(value)}
/>
)}
/>
{total > 0 && (
<Stack gap={10} mt={4}>
{documents.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{doc.name}
</Text>
{doc.size ? (
<Text size="xs" c="dimmed">
{formatSize(doc.size)}
</Text>
) : null}
</Box>
<Box
style={{
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 6,
color: "#0A6F4D",
}}
>
<CheckCircle2 size={15} />
<Text size="xs" fw={600} c="#0A6F4D">
Uploaded
</Text>
</Box>
</Group>
))}
</Stack>
)}
</StepCard>
);
}

View File

@@ -152,10 +152,11 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
>
<Stack gap={3}>
<Text fw={800} fz={18} c="edr-text.0">
Select a shipment date
Estimated shipment date
</Text>
<Text fz={13} c="edr-muted">
Confirmed train departures · {originName} {destinationName}
Planning only · pick from scheduled departures · {originName} {" "}
{destinationName}
</Text>
</Stack>
<Group gap={10}>
@@ -189,8 +190,8 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
? `${availableCount} scheduled departure day${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick your estimated date`
: "Select origin and destination to see scheduled departures"}
</Text>
{/* Weekday headers */}
@@ -284,14 +285,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DAY
ESTIMATED DATE
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Text fz={12.5} c="edr-muted">
Your train is confirmed by our freight desk after booking.
A planning estimate. You'll confirm the actual shipment date
later when you request the operation.
</Text>
</Stack>
</Box>

View File

@@ -0,0 +1,138 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
ArrowDownToLine,
ArrowUpFromLine,
PackageCheck,
PackageOpen,
Truck,
} from "lucide-react";
import { Text } from "@mantine/core";
import {
BookingFormInputValues,
type BookingFormValues,
type OperationType,
} from "./schema";
import {
AlertBox,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
const OPTIONS: Array<{
value: OperationType;
title: string;
description: string;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}> = [
{
value: "import",
title: "Import",
description: "Cargo arriving into Ethiopia via Djibouti.",
icon: <ArrowDownToLine className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export",
title: "Export",
description: "Cargo leaving Ethiopia bound for Djibouti.",
icon: <ArrowUpFromLine className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
{
value: "intercity",
title: "Intercity",
description: "Domestic movement between Ethiopian yards.",
icon: <Truck className="h-5 w-5" />,
iconBg: "#F1ECFB",
iconColor: "#6A40B8",
},
{
value: "import_ff",
title: "Import as FF",
description: "Import handled on behalf of a client as a freight forwarder.",
icon: <PackageOpen className="h-5 w-5" />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
},
{
value: "export_ff",
title: "Export as FF",
description: "Export handled on behalf of a client as a freight forwarder.",
icon: <PackageCheck className="h-5 w-5" />,
iconBg: "#EAF1FB",
iconColor: "#2E5B96",
},
];
export function Step0OperationType({
form,
allowedOperations,
onSelect,
}: {
form: BookingForm;
allowedOperations: OperationType[];
onSelect?: (op: OperationType) => void;
}) {
return (
<StepCard>
<StepHeader
icon={<Truck size={22} />}
title="Operation Type"
description="Choose what this booking is for. The options available reflect the operations your company is registered for."
/>
{allowedOperations.length === 0 && (
<AlertBox tone="error">
Your company has no operational profile yet. Complete onboarding to
register as an importer, exporter, or freight forwarder.
</AlertBox>
)}
<Controller
name="operationType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-3">
{OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value),
).map((opt) => (
<OptionCard
key={opt.value}
selected={field.value === opt.value}
icon={opt.icon}
iconBg={opt.iconBg}
iconColor={opt.iconColor}
title={opt.title}
description={opt.description}
onClick={() => {
field.onChange(opt.value);
onSelect?.(opt.value);
}}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
<Text fz={12} c="edr-muted" mt={14}>
Import and Export are stamped to your matching company profile; their
documents are attached automatically at submission.
</Text>
</StepCard>
);
}

View File

@@ -88,18 +88,19 @@ export function Step1ContractType({
const serviceId = service?.id || booking.serviceTypeId;
if (serviceId) form.setValue("serviceTypeId", serviceId);
// ── First / last mile ───────────────────────────────────────────────
form.setValue("firstMile.enabled", booking.firstMileEnabled);
if (booking.firstMilePickupAddress) {
form.setValue("firstMile.pickUpAddress", booking.firstMilePickupAddress);
}
form.setValue("lastMile.enabled", booking.lastMileEnabled);
if (booking.lastMileDeliveryAddress) {
form.setValue(
"lastMile.deliveryAddress",
booking.lastMileDeliveryAddress,
);
}
// ── First / last mile (address + map coordinates) ───────────────────
form.setValue("firstMile", {
enabled: booking.firstMileEnabled,
pickUpAddress: booking.firstMilePickupAddress ?? "",
lat: booking.firstMilePickupLat ?? null,
lng: booking.firstMilePickupLng ?? null,
});
form.setValue("lastMile", {
enabled: booking.lastMileEnabled,
deliveryAddress: booking.lastMileDeliveryAddress ?? "",
lat: booking.lastMileDeliveryLat ?? null,
lng: booking.lastMileDeliveryLng ?? null,
});
// ── Equipment return ────────────────────────────────────────────────
form.setValue(
@@ -110,9 +111,11 @@ export function Step1ContractType({
);
// ── Customs ─────────────────────────────────────────────────────────
if (service) {
form.setValue("customsClearingEnabled", service.includesCustoms);
}
form.setValue(
"customsClearingEnabled",
booking.customsClearingEnabled ?? service?.includesCustoms ?? false,
);
form.setValue("customsClearingAgent", booking.customsClearingAgent ?? "");
// ── Route ───────────────────────────────────────────────────────────
if (booking.originYard?.id) {
@@ -122,16 +125,6 @@ export function Step1ContractType({
form.setValue("destinationYard", booking.destinationYard.id);
}
// ── Shipping line ───────────────────────────────────────────────────
if (booking.shippingLineId && referenceData?.shipping_line) {
const shippingLine = referenceData.shipping_line.find(
(sl) => sl.id === booking.shippingLineId,
);
if (shippingLine) {
form.setValue("shippingLine", shippingLine.id);
}
}
// ── Cargo type ──────────────────────────────────────────────────────
form.setValue(
"cargoType",
@@ -139,8 +132,15 @@ export function Step1ContractType({
);
// ── Cargo weight (bulk) ─────────────────────────────────────────────
// Carry the prior amount into both the cargo-step weight (one-time path)
// and the primary route quantity (general-contract path) so whichever input
// is shown is prefilled.
if (booking.cargoTotalWeightVgm > 0) {
form.setValue("cargoWeight", String(booking.cargoTotalWeightVgm));
form.setValue(
"primaryRouteQuantity",
String(booking.cargoTotalWeightVgm),
);
}
// ── Hazardous / refrigerated ────────────────────────────────────────

View File

@@ -13,6 +13,7 @@ import {
StepLabel,
} from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "./LocationPicker";
import type { Freight } from "@edr/types";
@@ -39,12 +40,13 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
@@ -52,7 +54,7 @@ export function Step2ServiceType({
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
@@ -122,28 +124,49 @@ export function Step2ServiceType({
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
// Toggling off clears the captured pick-up location below.
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Box mt="md">
<Controller
name="firstMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Pick-up location"
placeholder="Search the pick-up address"
error={
(
fieldState.error as {
pickUpAddress?: { message?: string };
}
)?.pickUpAddress?.message
}
value={{
address: mf.value?.pickUpAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)}
</ServiceToggle>
)}
@@ -164,10 +187,12 @@ export function Step2ServiceType({
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
// Toggling off clears the captured delivery location below.
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
@@ -175,20 +200,39 @@ export function Step2ServiceType({
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Box mt="md">
<Controller
name="lastMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Delivery location"
placeholder="Search the delivery address"
error={
(
fieldState.error as {
deliveryAddress?: { message?: string };
}
)?.deliveryAddress?.message
}
value={{
address: mf.value?.deliveryAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)}
</ServiceToggle>
)}
@@ -229,8 +273,33 @@ export function Step2ServiceType({
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</ServiceToggle>
)}
/>
)}

View File

@@ -1,8 +1,29 @@
import type { Freight } from "@edr/types";
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
Box,
Button,
Divider,
Group,
NumberInput,
Skeleton,
Stack,
Switch,
Text,
} from "@mantine/core";
import {
Flame,
MapPin,
Plus,
Route as RouteIcon,
Snowflake,
Trash2,
} from "lucide-react";
import { useMemo } from "react";
import {
Controller,
useFieldArray,
type UseFormReturn,
} from "react-hook-form";
import {
BookingFormInputValues,
type BookingFormValues,
@@ -27,27 +48,19 @@ export function Step4Route({
}) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const isGeneralContract = form.watch("bookingType") === "general_contract";
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
// The form keys shipping line by ID (required by API as UUID).
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.id, label: sl.name });
}
return options;
}, [referenceData]);
const originData = useMemo(() => {
return yardOptions
.filter((o) => o.value !== destinationYard)
@@ -82,14 +95,28 @@ export function Step4Route({
DOMESTIC: "Domestic corridor",
};
useEffect(() => {
if (direction === "DOMESTIC") {
form.setValue("shippingLine", "", { shouldDirty: true });
}
}, [direction]);
const stationSelectDisabled = yardOptions.length === 0;
// General contracts reserve quantity per route. The unit (items vs tons) comes
// from the commodity picked in the cargo step, mirroring step5-cargo-details:
// PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
// contracts reserve quantity by container count instead, so no quantity input
// is shown for them here.
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const isContainer = cargoType === "container";
const selectedCommodity = useMemo(() => {
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, cargoTypePath]);
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
const quantityStep = isPerItem ? 1 : 0.01;
const showRouteQuantity = isGeneralContract && !isContainer;
return (
<StepCard>
<StepHeader
@@ -141,23 +168,146 @@ export function Step4Route({
{directionLabel[direction]}
</div>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
<Controller
name="primaryRouteQuantity"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${quantityLabel} *`}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
description="Quantity reserved on the primary route."
min={0}
step={quantityStep}
error={fieldState.error?.message}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
)}
</div>
)}
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
data={shippingLineOptions}
/>
)}
/>
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({
originYard: "",
destinationYard: "",
quantity: "",
km: "",
})
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can reserve quantity across several routes. The
route above is your primary route; add more routes and set the
quantity reserved for each.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ width: 140 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label={quantityLabel}
placeholder="0"
min={0}
step={quantityStep}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Box style={{ width: 110 }}>
<Controller
name={`extraRoutes.${i}.km`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Distance (km)"
placeholder="0"
min={0}
step={1}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
))}
</Stack>
</Box>
)}
<Divider my={22} color="#EEF2F6" />

View File

@@ -1,17 +1,11 @@
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import {
ActionIcon,
Button,
Skeleton,
Text,
TextInput,
} from "@mantine/core";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
calcWagons,
calcWagons,
type BookingFormValues,
} from "./schema";
import {
@@ -33,12 +27,10 @@ type BookingForm = UseFormReturn<
export function Step5CargoDetails({
form,
direction,
referenceData,
isLoading,
}: {
form: BookingForm;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
isLoading?: boolean;
}) {
@@ -47,6 +39,10 @@ export function Step5CargoDetails({
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
const containers = form.watch("containers");
// General contracts reserve quantity per route, captured in the route step
// against each route. So the single cargo-level quantity below is only asked
// for one-time bookings; contracts skip it here to avoid a duplicate input.
const isGeneralContract = form.watch("bookingType") === "general_contract";
const { fields, append, remove } = useFieldArray({
control: form.control,
@@ -72,10 +68,10 @@ export function Step5CargoDetails({
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → "Items", else "Tons".
// Drives the weight/quantity label so customers enter the right unit.
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → ask for a total item
// count; otherwise ask for an estimated tonnage. Drives the quantity field's
// label, icon, and step so customers enter the right unit.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const bulkUnitLabel = isPerItem ? "Items" : "Tons";
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
@@ -104,29 +100,13 @@ export function Step5CargoDetails({
);
}, [referenceData, parentId]);
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
): string | null {
if (type === "20ft" && vgm > 0) {
const limit = direction === "EXPORT" ? 25 : 20;
if (vgm > limit) {
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
}
}
if (type === "40ft" && vgm > 32.5) {
return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`;
}
return null;
}
if (isLoading) {
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
description="Choose your cargo type and configuration."
/>
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
@@ -146,7 +126,7 @@ export function Step5CargoDetails({
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
description="Choose your cargo type and configuration. Container weight is captured later in operations."
/>
{/* Cargo Type */}
@@ -189,34 +169,8 @@ export function Step5CargoDetails({
/>
</div>
{/* Weight */}
<div className="space-y-3">
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="cargoWeight"
type="number"
label={
cargoType === "bulk"
? `Total Cargo Quantity (${bulkUnitLabel}) *`
: "Total Cargo Weight (Tons) *"
}
placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
min={0}
step={isPerItem ? 1 : 0.01}
/>
)}
/>
</div>
{/* Bulk freight type */}
{/* Bulk freight type — pick the commodity FIRST so we know whether the
cargo is measured in tons or items before asking for the quantity. */}
{cargoType === "bulk" && (
<div className="space-y-3">
{freightTypeOptions.length > 0 ? (
@@ -247,8 +201,8 @@ export function Step5CargoDetails({
<SelectField
field={field}
error={fieldState.error}
label=""
placeholder="Select type *"
label="Commodity *"
placeholder="Select type..."
data={commodityOptions}
/>
)}
@@ -270,10 +224,47 @@ export function Step5CargoDetails({
)}
/>
)}
{/* Quantity — only once a commodity is chosen, so the unit (tons vs
items) is known. PER_TON asks for estimated tons; PER_ITEM asks
for the total item count. General contracts collect this per route
in the route step instead, so it's hidden here for them. */}
{selectedCommodity && !isGeneralContract && (
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="cargoWeight"
type="number"
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
leftSection={
isPerItem ? (
<Package className="h-4 w-4" />
) : (
<Weight className="h-4 w-4" />
)
}
error={fieldState.error?.message}
description={
isPerItem
? "Total count of items you plan to import or export."
: "Estimated total tonnage to ship."
}
radius={10}
styles={fieldStyles}
min={0}
step={isPerItem ? 1 : 0.01}
/>
)}
/>
)}
</div>
)}
{/* Container list */}
{/* Container list — type + quantity only; no weight is collected here. */}
{cargoType === "container" && (
<>
<div className="space-y-4">
@@ -286,190 +277,148 @@ export function Step5CargoDetails({
radius="md"
leftSection={<Plus size={14} />}
onClick={() =>
append({ type: "20ft", containerType: "", qty: "1", vgm: "" })
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "0",
})
}
>
Add Container
</Button>
</div>
{fields.map((field, index) => {
const containerType = containers[index]?.type;
const vgm = containers[index]?.vgm ?? 0;
const alert = getOverweightAlert(containerType, +vgm);
return (
<div
key={field.id}
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text
size="xs"
fw={600}
c="dimmed"
tt="uppercase"
className="tracking-wide"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
aria-label="Remove container"
>
Container {index + 1}
</Text>
{fields.length > 1 && (
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
aria-label="Remove container"
>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
{/* Container size */}
{/* Container size */}
<Controller
name={`containers.${index}.type`}
control={form.control}
render={({ field: typeField, fieldState }) => (
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{ val: "20ft" as const, label: "20ft Container (TEU)" },
{ val: "40ft" as const, label: "40ft Container (FEU)" },
].map((ct) => (
<OptionCard
key={ct.val}
selected={typeField.value === ct.val}
onClick={() => typeField.onChange(ct.val)}
>
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
</OptionCard>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{/* Quantity + Container Type */}
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name={`containers.${index}.type`}
name={`containers.${index}.qty`}
control={form.control}
render={({ field: typeField, fieldState }) => (
render={({ field: qtyField, fieldState }) => (
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
val: "20ft" as const,
label: "20ft Container (TEU)",
limit:
direction === "EXPORT"
? "Max 25t per container"
: "Max 20t per container",
},
{
val: "40ft" as const,
label: "40ft Container (FEU)",
limit: "Max 32.5t per container",
},
].map((ct) => (
<OptionCard
key={ct.val}
selected={typeField.value === ct.val}
onClick={() => typeField.onChange(ct.val)}
>
<div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-gray-500">
{ct.limit}
</p>
</OptionCard>
))}
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) => qtyField.onChange(e.target.value)}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
<OptionFieldError error={fieldState.error} />
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
{/* Qty + VGM + Type */}
<div className="grid gap-3 sm:grid-cols-3">
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
</button>
<input
value={qtyField.value ?? 1}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur}
type="number"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
<Controller
name={`containers.${index}.vgm`}
control={form.control}
render={({ field: vgmField, fieldState }) => (
<TextInput
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
label="Tons *"
placeholder="e.g. 18.5"
error={fieldState.error?.message}
radius="md"
min={0}
step={0.1}
/>
)}
/>
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>
</div>
{alert && (
<AlertBox tone="warning">
<strong>Overweight Alert:</strong> {alert}
</AlertBox>
)}
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
data={
containerTypeOptionsBySize.get(
containers[index]?.type ?? "20ft",
) ?? []
}
/>
)}
/>
</div>
);
})}
</div>
))}
</div>
{(() => {
@@ -479,10 +428,10 @@ export function Step5CargoDetails({
<AlertBox tone="warning">
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the remaining
slot, which <strong>may delay departure</strong> beyond the
standard lead time.
One 20ft container occupies only half a wagon. The wagon will
depart once a co-loader is found to fill the remaining slot,
which <strong>may delay departure</strong> beyond the standard
lead time.
</p>
</AlertBox>
);

View File

@@ -24,10 +24,7 @@ import {
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
@@ -36,8 +33,8 @@ import { StepHeader } from "./shared";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
cargo: 3,
route: 4,
schedule: 5,
documents: 6,
} as const;
@@ -133,6 +130,7 @@ export function Step8Review({
setStep,
direction,
referenceData,
onboardingDocs = [],
onSaveDraft,
onSubmit,
saveDraftPending = false,
@@ -142,6 +140,7 @@ export function Step8Review({
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
onboardingDocs?: Array<{ name: string; size?: number }>;
onSaveDraft?: () => void;
onSubmit?: () => void;
saveDraftPending?: boolean;
@@ -151,9 +150,6 @@ export function Step8Review({
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
const shippingLine = referenceData?.shipping_line.find(
(sl) => sl.id === values.shippingLine,
);
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
@@ -163,20 +159,31 @@ export function Step8Review({
.join(", ")
: "";
// For bulk general contracts the quantity is reserved per route (the primary
// route's amount lives in primaryRouteQuantity); one-time bookings use the
// cargo-step cargoWeight.
const isGeneralContract = values.bookingType === "general_contract";
const bulkAmount =
isGeneralContract && values.cargoType === "bulk"
? Number(values.primaryRouteQuantity || 0)
: Number(values.cargoWeight || 0);
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
(sum, c) => sum + (+c.qty || 0) * (+(c.vgm ?? 0) || 0),
0,
)
: Number(values.cargoWeight || 0);
: bulkAmount;
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const allDocsReady = hasAllRequiredDocuments(documents);
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
const selectedCommodity = (() => {
if (values.cargoType !== "bulk" || !referenceData) return null;
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
return group?.children?.find((c) => c.id === path[1]) ?? null;
})();
const cargoValue = (() => {
if (values.cargoType === "container") return "Container freight";
@@ -188,6 +195,18 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
// Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const totalQuantityRow = isPerItem
? {
label: "Total quantity",
value: totalVgm > 0 ? `${Math.round(totalVgm)} items` : "—",
}
: {
label: "Total VGM",
value: totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—",
};
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
@@ -277,7 +296,6 @@ export function Step8Review({
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={shippingLine?.name || "—"} />
<DetailRow
label="Modifiers"
value={
@@ -319,7 +337,13 @@ export function Step8Review({
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
value={
values.customsClearingEnabled
? values.customsClearingAgent
? `Enabled — agent: ${values.customsClearingAgent}`
: "Enabled"
: "Not requested"
}
/>
</OverviewSection>
@@ -328,7 +352,7 @@ export function Step8Review({
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
<OverviewSection
@@ -337,17 +361,19 @@ export function Step8Review({
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
{/* Containers carry no weight at the wizard — only bulk shows a quantity row. */}
{values.cargoType === "bulk" && (
<DetailRow
label={totalQuantityRow.label}
value={totalQuantityRow.value}
/>
)}
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -357,7 +383,6 @@ export function Step8Review({
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -374,35 +399,35 @@ export function Step8Review({
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{BOOKING_DOCS_SETTING.fields.map((field) => {
const file = documents[field.fileKey];
const attached = Array.isArray(file)
? file.length > 0
: Boolean(file);
const fileName = attached
? Array.isArray(file)
? file[0]?.name
: (file as File)?.name
: null;
return (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
{onboardingDocsCount > 0 ? (
onboardingDocs.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
wrap="nowrap"
>
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<Text size="sm" className="truncate max-w-[60%]">
{doc.name}
</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
<Text size="xs" c="dimmed">
Uploaded
</Text>
</Group>
);
})}
))
) : (
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No onboarding documents found on your active profile.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
Documents from your onboarding will be attached to this booking.
</Text>
</OverviewSection>
@@ -446,22 +471,21 @@ export function Step8Review({
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
: bulkAmount > 0
}
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
done={onboardingDocsCount > 0}
label="Onboarding documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{allDocsReady
? "Ready to submit. You'll review the price estimate before final submission."
: "Upload all four documents to enable submission."}
Ready to submit. You'll review the unit rates before final
submission.
</Text>
<Stack gap="sm">
<Button
@@ -473,7 +497,7 @@ export function Step8Review({
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
disabled={submitPending}
>
Submit
</Button>

View File

@@ -1,3 +1,4 @@
export { Step0OperationType } from "./step0-operation-type";
export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step4Route } from "./step4-route";

View File

@@ -1,7 +1,8 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
Card,
@@ -10,6 +11,7 @@ import {
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
@@ -18,14 +20,15 @@ import {
import {
ArrowLeft,
CalendarClock,
Inbox,
Layers,
MapPin,
PackageCheck,
PackagePlus,
Ship,
} from "lucide-react";
import { api } from "@/services/api";
import { ModeIndicator } from "@/components/ModeIndicator";
import { PayNowButton } from "../bookings/payments/PayNowButton";
import {
BORDER,
@@ -35,6 +38,7 @@ import {
INK,
MetaItem,
MUTED,
StatCard,
} from "./contract-ui";
import { PlaceOrderDialog } from "./PlaceOrderDialog";
@@ -95,6 +99,18 @@ export default function ContractDetailPage() {
const isActive = contract.status === "CONTRACT_ACTIVE";
const awaitingPayment = contract.status === "FULLY_EXECUTED";
const poolLines = pool ?? [];
const showPool = contract.status !== "DRAFT";
// Overall utilization across every pool line — drives the header ring + stat.
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
return (
<Box style={{ padding: "28px 32px 40px" }}>
@@ -112,8 +128,8 @@ export default function ContractDetailPage() {
<ArrowLeft size={18} />
</Button>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={46} radius="md" variant="light" color="violet">
<Layers size={22} />
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
<Layers size={23} />
</ThemeIcon>
<div>
<Group gap={10} align="center">
@@ -121,21 +137,24 @@ export default function ContractDetailPage() {
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
<ModeIndicator size="sm" />
</Group>
<Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"}
General contract · {isContainer ? "Containerised" : "Bulk"} ·{" "}
{contract.tradeDirection ?? "—"}
</Text>
</div>
</Group>
</Group>
<Group gap="sm">
{awaitingPayment && <PayNowButton booking={contract} label="Pay & activate" size="sm" />}
{awaitingPayment && (
<PayNowButton booking={contract} label="Pay & activate" size="sm" />
)}
{isActive && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() => setOrderOpen(true)}
>
@@ -145,7 +164,7 @@ export default function ContractDetailPage() {
</Group>
</Group>
{/* Summary */}
{/* Summary meta */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={48} wrap="wrap">
<MetaItem
@@ -170,15 +189,61 @@ export default function ContractDetailPage() {
</Group>
</Paper>
{/* Stat strip */}
{showPool && (
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Orders placed"
value={orders?.length ?? 0}
icon={PackageCheck}
color="violet"
/>
<StatCard
label="Utilization"
hint="of reserved quantity"
value={`${totals.pct}%`}
icon={PackageCheck}
color="edr-green"
/>
<StatCard
label="Ordering until"
value={
contract.expiresAt
? new Date(contract.expiresAt).toLocaleDateString()
: "—"
}
icon={CalendarClock}
color="edr-accent"
/>
</Group>
)}
{/* Drawdown pool */}
{contract.status !== "DRAFT" && (
{showPool && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb={4} style={{ color: INK }}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mb="lg">
How much of this contract has been ordered versus what remains.
</Text>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
<Box>
<Text fw={700} fz={16} style={{ color: INK }}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mt={2}>
How much of this contract has been ordered versus what remains.
</Text>
</Box>
{totals.contracted > 0 && (
<RingProgress
size={72}
thickness={7}
roundCaps
sections={[{ value: totals.pct, color: "edr-green" }]}
label={
<Text ta="center" fz={13} fw={800} style={{ color: INK }}>
{totals.pct}%
</Text>
}
/>
)}
</Group>
<Stack gap="lg">
{poolLines.length === 0 && (
<Text fz={13} c="dimmed">
@@ -198,14 +263,26 @@ export default function ContractDetailPage() {
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
const depleted = line.remainingQuantity <= 0;
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Group gap={8} align="center">
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
{depleted && (
<Badge size="xs" variant="light" color="gray" radius="sm">
Fully ordered
</Badge>
)}
</Group>
<Text fz={13} c="dimmed">
<Text span fw={700} style={{ color: GREEN }}>
<Text
span
fw={700}
style={{ color: depleted ? MUTED : GREEN }}
>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
@@ -222,7 +299,7 @@ export default function ContractDetailPage() {
</Group>
<Progress
value={pct}
color="edr-green"
color={depleted ? "gray" : "edr-green"}
size="md"
radius="xl"
/>
@@ -235,46 +312,64 @@ export default function ContractDetailPage() {
{/* Orders */}
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb="md" style={{ color: INK }}>
Orders ({orders?.length ?? 0})
</Text>
{!orders || orders.length === 0 ? (
<Text fz={13} c="dimmed">
{isActive
? "No orders yet. Use “Place order” to draw down from this contract."
: "Orders can be placed once the contract is active (paid)."}
<Group justify="space-between" align="center" mb="md">
<Text fw={700} fz={16} style={{ color: INK }}>
Orders
</Text>
<Badge variant="light" color="violet" radius="sm">
{orders?.length ?? 0}
</Badge>
</Group>
{!orders || orders.length === 0 ? (
<Stack align="center" gap={8} py="xl">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<Inbox size={22} />
</ThemeIcon>
<Text fz={13} c="dimmed" ta="center" maw={360}>
{isActive
? "No orders yet. Use “Place order” to draw down from this contract."
: "Orders can be placed once the contract is active (paid)."}
</Text>
</Stack>
) : (
<Stack gap={0}>
{orders.map((order, idx) => (
<Box
<Stack gap={10}>
{orders.map((order) => (
<Group
key={order.id}
py="sm"
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderTop: idx === 0 ? undefined : `1px solid ${BORDER}`,
borderRadius: 12,
border: `1px solid ${BORDER}`,
}}
>
<Group justify="space-between" wrap="nowrap">
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="violet">
<PackageCheck size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{order.reference}
</Text>
<Text fz={12} c="dimmed">
<Text fz={12} c="dimmed" truncate>
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
l.containerTypeName
? ` ${l.containerTypeName}`
: ""
}`,
)
.join(", ")}
</Text>
</div>
<ContractStatusBadge status={order.status} />
</Box>
</Group>
</Box>
<ContractStatusBadge status={order.status} />
</Group>
))}
</Stack>
)}

View File

@@ -14,7 +14,15 @@ import {
ThemeIcon,
Title,
} from "@mantine/core";
import { Layers, Plus, Search, X } from "lucide-react";
import {
CheckCircle2,
FileStack,
Layers,
Plus,
Search,
Timer,
X,
} from "lucide-react";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
@@ -25,9 +33,8 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { ModeIndicator } from "@/components/ModeIndicator";
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
import { BORDER, ContractStatusBadge, INK, StatCard } from "./contract-ui";
export default function ContractsList() {
const navigate = useNavigate();
@@ -83,11 +90,22 @@ export default function ContractsList() {
);
}, [data, query]);
const activeCount = useMemo(
() =>
(data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length,
[data],
);
const stats = useMemo(() => {
const items = data?.items ?? [];
const active = items.filter((b) => b.status === "CONTRACT_ACTIVE").length;
const pending = items.filter((b) =>
[
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(b.status),
).length;
const total = data?.meta?.total ?? items.length;
return { active, pending, total };
}, [data]);
const columns: ColumnDef<Freight.IBooking>[] = [
{
@@ -172,21 +190,24 @@ export default function ContractsList() {
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Group gap={10} align="center">
<Group gap={14} wrap="nowrap" align="center">
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
<Layers size={24} />
</ThemeIcon>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
General Contracts
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the
contract runs out or its window closes.
</Text>
</Box>
<Text size="sm" c="edr-muted" mt={4} maw={520}>
Reserve a quantity once, then place orders against it until the
contract runs out or its window closes.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
>
@@ -194,78 +215,97 @@ export default function ContractsList() {
</Button>
</Group>
{/* Summary */}
<SimpleStat
label="Active contracts"
value={activeCount}
hint="accepting orders"
/>
{/* Summary strip */}
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Active"
hint="accepting orders"
value={stats.active}
icon={CheckCircle2}
color="edr-green"
/>
<StatCard
label="In progress"
hint="setup / signing"
value={stats.pending}
icon={Timer}
color="edr-accent"
/>
<StatCard
label="Total contracts"
value={stats.total}
icon={FileStack}
color="violet"
/>
</Group>
{/* Search + filters */}
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
styles={{ input: { height: 44 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created from"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created to"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
styles={{ input: { height: 42 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
styles={{ input: { height: 42 } }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 152 }}
styles={{ input: { height: 42 } }}
aria-label="Created from"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 152 }}
styles={{ input: { height: 42 } }}
aria-label="Created to"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
</Paper>
{/* Table */}
<Card p={0} style={{ overflow: "hidden" }}>
@@ -304,37 +344,3 @@ function ColHeader({ label }: { label: string }) {
</Text>
);
}
function SimpleStat({
label,
value,
hint,
}: {
label: string;
value: number | string;
hint?: string;
}) {
return (
<Paper
withBorder
radius="lg"
p="md"
maw={260}
style={{ borderColor: "#E6ECF2" }}
>
<Text fz={12} fw={600} c="dimmed">
{label}
</Text>
<Group gap={8} align="baseline" mt={2}>
<Text fz={28} fw={800} style={{ color: GREEN }}>
{value}
</Text>
{hint && (
<Text fz={12} style={{ color: MUTED }}>
{hint}
</Text>
)}
</Group>
</Paper>
);
}

View File

@@ -8,6 +8,7 @@ import {
NumberInput,
Select,
Stack,
Switch,
Text,
} from "@mantine/core";
import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react";
@@ -41,15 +42,38 @@ export function PlaceOrderDialog({
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
const [routeLineId, setRouteLineId] = useState<string | null>(null);
// Per-order hazardous / reefer counts, entered once when the toggle is on.
const [hazardousOn, setHazardousOn] = useState(false);
const [hazardousQty, setHazardousQty] = useState<number | "">("");
const [reeferOn, setReeferOn] = useState(false);
const [reeferQty, setReeferQty] = useState<number | "">("");
// Multi-route contracts expose route lines; single-route contracts return [].
const { data: routeLines = [] } = useQuery({
...api.bookingOrders.routes.queryOptions({
input: { contractBookingId: contract.id },
}),
enabled: opened,
});
const isMultiRoute = routeLines.length > 0;
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
// The route the order ships on drives both the available-days query and the
// remaining-quantity check: the chosen route line for multi-route contracts,
// else the contract's own origin/destination.
const originYardId = isMultiRoute
? selectedRoute?.originYardId
: contract.originYard?.id;
const destinationYardId = isMultiRoute
? selectedRoute?.destinationYardId
: contract.destinationYard?.id;
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.bookings.getAvailableDays.queryOptions({
input: {
originYardId: contract.originYard?.id,
destinationYardId: contract.destinationYard?.id,
},
input: { originYardId, destinationYardId },
}),
enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id,
enabled: opened && !!originYardId && !!destinationYardId,
});
const dayOptions = useMemo(
@@ -82,6 +106,11 @@ export function PlaceOrderDialog({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookingOrders.routes.queryKey({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: contract.id }),
});
@@ -93,8 +122,29 @@ export function PlaceOrderDialog({
function reset() {
setScheduledDate(null);
setQuantities({});
setRouteLineId(null);
setHazardousOn(false);
setHazardousQty("");
setReeferOn(false);
setReeferQty("");
}
// Total quantity across the order; haz/reefer counts cannot exceed it.
const orderTotalQty = isMultiRoute
? typeof quantities["__route__"] === "number"
? (quantities["__route__"] as number)
: 0
: pool.reduce((sum, l) => {
const raw = quantities[lineKey(l)];
return sum + (typeof raw === "number" ? raw : 0);
}, 0);
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
const hazReeferValid =
(!hazardousOn || (hazValue > 0 && hazValue <= orderTotalQty)) &&
(!reeferOn || (reeferValue > 0 && reeferValue <= orderTotalQty));
function handleClose() {
if (createMutation.isPending) return;
reset();
@@ -103,6 +153,31 @@ export function PlaceOrderDialog({
function handleSubmit() {
if (!scheduledDate) return;
if (isMultiRoute) {
if (!selectedRoute) return;
const raw = quantities["__route__"];
const qty = typeof raw === "number" ? raw : 0;
if (qty <= 0) return;
if (!hazReeferValid) return;
createMutation.mutate({
contractBookingId: contract.id,
routeLineId: selectedRoute.routeLineId,
scheduledDate: new Date(scheduledDate).toISOString(),
lines: [
{
containerTypeId: isContainer
? (selectedRoute.containerTypeId ?? null)
: null,
quantity: qty,
hazardousQuantity: hazValue,
reeferQuantity: reeferValue,
},
],
});
return;
}
const lines: Freight.CreateBookingOrderLineDto[] = pool
.map((line) => {
const raw = quantities[lineKey(line)];
@@ -115,6 +190,14 @@ export function PlaceOrderDialog({
.filter((l) => l.quantity > 0);
if (lines.length === 0) return;
if (!hazReeferValid) return;
// Haz/reefer are entered once per order; attach the counts to the first line.
lines[0] = {
...lines[0],
hazardousQuantity: hazValue,
reeferQuantity: reeferValue,
};
createMutation.mutate({
contractBookingId: contract.id,
@@ -124,11 +207,28 @@ export function PlaceOrderDialog({
}
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
const hasQuantity = pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending;
const routeQtyRaw = quantities["__route__"];
const hasQuantity = isMultiRoute
? typeof routeQtyRaw === "number" && routeQtyRaw > 0
: pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit =
!!scheduledDate &&
hasQuantity &&
hazReeferValid &&
(!isMultiRoute || !!selectedRoute) &&
!createMutation.isPending;
const routeOptions = routeLines.map((r) => ({
value: r.routeLineId,
label: `${r.originYardName ?? r.originYardId}${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity(
r.remainingQuantity,
null,
isContainer,
)} remaining`,
}));
return (
<Modal
@@ -148,18 +248,35 @@ export function PlaceOrderDialog({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Draw down from contract <strong>{contract.reference}</strong>. Route,
cargo and service are inherited just pick a shipment date and
quantity.
Draw down from contract <strong>{contract.reference}</strong>. Cargo
and service are inherited pick {isMultiRoute ? "a route, " : ""}a
shipment date and quantity.
</Text>
{isMultiRoute && (
<Select
label="Route"
placeholder="Select a contracted route"
data={routeOptions}
value={routeLineId}
onChange={(v) => {
setRouteLineId(v);
setScheduledDate(null);
setQuantities({});
}}
radius="md"
comboboxProps={{ withinPortal: true }}
styles={{ input: { height: 44 } }}
/>
)}
<Select
label="Shipment date"
placeholder={daysLoading ? "Loading available days…" : "Select a day"}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
disabled={daysLoading}
disabled={daysLoading || (isMultiRoute && !selectedRoute)}
radius="md"
leftSection={<CalendarDays size={16} />}
nothingFoundMessage="No departures on this route"
@@ -168,6 +285,52 @@ export function PlaceOrderDialog({
styles={{ input: { height: 44 } }}
/>
{isMultiRoute ? (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{!selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route to draw down from.
</Text>
) : selectedRoute.remainingQuantity <= 0 ? (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This route is fully drawn down no quantity remains.
</Alert>
) : (
<Group justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{selectedRoute.containerTypeName ??
(isContainer ? "Containers" : "Tons")}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
selectedRoute.remainingQuantity,
null,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities["__route__"] ?? ""}
onChange={(v) =>
setQuantities({ __route__: v === "" ? "" : Number(v) })
}
min={0}
max={selectedRoute.remainingQuantity}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
)}
</Stack>
) : (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
@@ -219,6 +382,68 @@ export function PlaceOrderDialog({
);
})}
</Stack>
)}
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Cargo handling
</Text>
<Group justify="space-between" wrap="nowrap" gap="md">
<Switch
label="Hazardous cargo"
checked={hazardousOn}
onChange={(e) => {
setHazardousOn(e.currentTarget.checked);
if (!e.currentTarget.checked) setHazardousQty("");
}}
color="edr-green"
/>
{hazardousOn && (
<NumberInput
value={hazardousQty}
onChange={(v) => setHazardousQty(v === "" ? "" : Number(v))}
min={0}
max={orderTotalQty || undefined}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="How many"
/>
)}
</Group>
<Group justify="space-between" wrap="nowrap" gap="md">
<Switch
label="Refrigerated (reefer)"
checked={reeferOn}
onChange={(e) => {
setReeferOn(e.currentTarget.checked);
if (!e.currentTarget.checked) setReeferQty("");
}}
color="edr-green"
/>
{reeferOn && (
<NumberInput
value={reeferQty}
onChange={(v) => setReeferQty(v === "" ? "" : Number(v))}
min={0}
max={orderTotalQty || undefined}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="How many"
/>
)}
</Group>
{(hazardousOn || reeferOn) && (
<Text fz={12} c="dimmed">
Hazardous/reefer quantity cannot exceed the order total
{orderTotalQty > 0 ? ` (${orderTotalQty})` : ""}. These add the
relevant surcharge to this order's price.
</Text>
)}
</Stack>
{createMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Text } from "@mantine/core";
import { Box, Badge, Group, Paper, Text, ThemeIcon } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
// Brand palette (mirrors the booking form's shared constants).
@@ -8,6 +9,48 @@ export const GREEN = "#0EA371";
export const GREEN_DARK = "#0A6F4D";
export const BORDER = "#E6ECF2";
/**
* A compact KPI tile used on the contracts list + detail header strips. Icon in
* a tinted chip, big value, small label — consistent with the app's house cards.
*/
export function StatCard({
label,
value,
hint,
icon: Icon,
color = "edr-green",
}: {
label: string;
value: ReactNode;
hint?: string;
icon: LucideIcon;
color?: string;
}) {
return (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, flex: 1, minWidth: 180 }}
>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={42} radius="md" variant="light" color={color}>
<Icon size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} style={{ color: INK, letterSpacing: "-0.02em" }}>
{value}
</Text>
<Text fz={12} fw={600} c="dimmed" truncate>
{label}
{hint ? ` · ${hint}` : ""}
</Text>
</Box>
</Group>
</Paper>
);
}
/** Visual config for a general-contract status. */
export const CONTRACT_STATUS_CONFIG: Record<
string,

View File

@@ -128,7 +128,7 @@ export const api = {
companiesService.updateProfile,
),
getDashboard: endpoint<void, DashboardSummary>(
getDashboard: endpoint<string | undefined, DashboardSummary>(
"companies",
"getDashboard",
companiesService.getDashboard,
@@ -225,6 +225,12 @@ export const api = {
({ id, reason }) => bookingsService.cancel(id, reason),
),
reject: endpoint<{ id: string; reason?: string }, Freight.IBooking>(
"bookings",
"reject",
({ id, reason }) => bookingsService.reject(id, reason),
),
generatePrice: endpoint<{ id: string }, GeneratePriceResponse>(
"bookings",
"generatePrice",
@@ -250,6 +256,25 @@ export const api = {
bookingsService.uploadDocuments(id, files),
),
getClearance: endpoint<{ id: string }, Freight.ClearanceView>(
"bookings",
"getClearance",
({ id }) => bookingsService.getClearance(id),
),
submitClearanceDocuments: endpoint<
{ id: string; files: Record<string, File | null> },
Freight.IBooking
>("bookings", "submitClearanceDocuments", ({ id, files }) =>
bookingsService.submitClearanceDocuments(id, files),
),
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
"proceedToOperation",
({ id }) => bookingsService.proceedToOperation(id),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
"bookings",
"checkPayment",
@@ -295,6 +320,13 @@ export const api = {
bookingOrdersService.pool(contractBookingId),
),
routes: endpoint<
{ contractBookingId: string },
Freight.ContractRouteLine[]
>("booking-orders", "routes", ({ contractBookingId }) =>
bookingOrdersService.routes(contractBookingId),
),
create: endpoint<CreateBookingOrderPayload, Freight.IBookingOrder>(
"booking-orders",
"create",

View File

@@ -24,6 +24,16 @@ export const bookingOrdersService = {
return data.data ?? data;
},
/** Per-route contracted / ordered / remaining quantities (multi-route contracts). */
routes: async (
contractBookingId: string,
): Promise<Freight.ContractRouteLine[]> => {
const { data } = await client.get(
`/api/booking-orders/contract/${contractBookingId}/routes`,
);
return data.data ?? data;
},
/** Place a drawdown order against a contract. */
create: async (
payload: CreateBookingOrderPayload,

View File

@@ -35,7 +35,14 @@ export interface ContractView {
export interface PriceLineItem {
code: string;
description: string;
/** Computed line total (unitAmount × quantity). */
amount: number;
/** Price for a single unit of this charge (e.g. one 20ft container, one ton). */
unitAmount?: number;
/** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */
unit?: string;
/** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */
quantity?: number;
currency: string;
}
@@ -75,6 +82,8 @@ export interface BookingListFilter {
freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string;
/** Narrow to a single operational profile (importer/exporter/freight_forwarder). */
companyProfileId?: string;
/** Created-date range (ISO). */
createdFrom?: string;
createdTo?: string;
@@ -134,6 +143,11 @@ export const bookingsService = {
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;
@@ -170,6 +184,33 @@ export const bookingsService = {
return data.data;
},
// ── Document clearance ──
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
const { data } = await client.get(`/api/bookings/${id}/clearance`);
return data.data ?? data;
},
submitClearanceDocuments: async (
id: string,
files: Record<string, File | null>,
): Promise<Freight.IBooking> => {
const formData = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) formData.append(key, file);
}
const { data } = await client.post(
`/api/bookings/${id}/clearance/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data;
},
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
return data.data;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;

View File

@@ -162,9 +162,12 @@ export const companiesService = {
return unwrap(response.data);
},
getDashboard: async (): Promise<DashboardSummary> => {
getDashboard: async (
companyProfileId?: string,
): Promise<DashboardSummary> => {
const response = await client.get<ApiResponse<DashboardSummary>>(
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
{ params: companyProfileId ? { companyProfileId } : undefined },
);
return unwrap(response.data);
},