mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, 1–365. */
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,6 @@ export type RuleEngineResourceSlug =
|
||||
| "wagon-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "surcharge-types"
|
||||
| "weight-limit-rules"
|
||||
| "yards"
|
||||
| "shipping-lines"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
@@ -51,7 +51,7 @@ export function PhoneField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
// onBlur,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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'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)}
|
||||
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user