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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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