mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +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"
|
||||
|
||||
Reference in New Issue
Block a user