Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 04:33:15 +03:00
38 changed files with 1769 additions and 363 deletions

View File

@@ -59,13 +59,17 @@ export function BookingConfirmDialog({
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) ||
(needsDaysInput && !daysValid);
(needsDaysInput && !daysValid) ||
(needsAmountInput && !amountValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -168,6 +172,19 @@ export function BookingConfirmDialog({
</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

@@ -15,6 +15,13 @@ function isValidValidityDays(value: string): boolean {
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,
@@ -77,6 +84,24 @@ export function useBookingActionDialog(
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;
@@ -126,7 +151,8 @@ export function useBookingActionDialog(
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
return {
actions,

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" | "days";
export type BookingActionInputKind =
| "note"
| "reason"
| "file"
| "days"
| "amount";
export interface BookingActionDef {
id: BookingActionId;
@@ -159,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",
@@ -210,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,
};
@@ -303,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

@@ -61,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,
@@ -148,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

@@ -2,27 +2,41 @@ 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";
@@ -30,85 +44,214 @@ 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 }),
queryFn: () =>
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
});
const bookings = list?.items ?? [];
const activeId = selectedId ?? bookings[0]?.id ?? null;
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 (
<Box p="lg">
<Group gap={10} mb="lg">
<ShieldCheck size={22} color="#0A6F4D" />
<Text fw={800} fz="22px" c="#10202F">
Document Clearance
</Text>
</Group>
<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-6 lg:flex-row lg:items-start">
<Card withBorder radius="md" p="sm" style={{ width: 300, flexShrink: 0 }}>
<Text fz="13px" fw={700} c="#10202F" mb="xs">
Awaiting review ({bookings.length})
</Text>
{isLoading && (
<Text fz="13px" c="dimmed">
Loading
<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>
)}
{!isLoading && bookings.length === 0 && (
<Text fz="13px" c="dimmed">
No bookings awaiting document review.
</Text>
)}
<Stack gap={6}>
{bookings.map((b) => (
<button
key={b.id}
type="button"
onClick={() => setSelectedId(b.id)}
style={{
textAlign: "left",
border: `1px solid ${b.id === activeId ? "#0A6F4D" : "#E6ECF2"}`,
background: b.id === activeId ? "#F4FBF7" : "#fff",
borderRadius: 10,
padding: "8px 10px",
cursor: "pointer",
}}
>
<Text fz="13px" fw={600} c="#10202F">
{b.reference}
</Text>
<Text fz="11.5px" c="dimmed">
{b.tradeDirection} · {b.freightType}
</Text>
</button>
))}
</Stack>
</Card>
{/* ── Review panel ─────────────────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
{activeId ? (
<ClearanceReviewPanel
key={activeId}
bookingId={activeId}
onChanged={() =>
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
}
/>
) : (
<Card withBorder radius="md" p="xl">
<Text c="dimmed">Select a booking to review its documents.</Text>
</Card>
<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,
@@ -118,6 +261,7 @@ function ClearanceReviewPanel({
}) {
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({
@@ -136,15 +280,20 @@ function ClearanceReviewPanel({
status: "APPROVED" | "QUERIED";
note?: string;
}) => bookingsService.reviewClearanceDocument(bookingId, p),
onSuccess: () => {
toast.success("Document updated");
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),
mutationFn: () =>
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
@@ -160,7 +309,9 @@ function ClearanceReviewPanel({
refresh();
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : "Could not finalize clearance"),
toast.error(
e instanceof Error ? e.message : "Could not finalize clearance",
),
});
const customerDocs = useMemo(
@@ -172,85 +323,151 @@ function ClearanceReviewPanel({
[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 radius="md" p="xl">
<Text c="dimmed">Loading clearance</Text>
<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">
<Card withBorder radius="md" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700} c="#10202F">
Customer documents
</Text>
{/* ── 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 ? (
<Group gap={6} c="#0A6F4D">
<CheckCircle2 size={16} />
<Text fz="12.5px" fw={600} c="#0A6F4D">
All approved
</Text>
</Group>
<Badge
variant="light"
color="edr-green"
radius="sm"
size="lg"
leftSection={<CheckCircle2 size={14} />}
>
All approved
</Badge>
) : (
<Group gap={6} c="#2E5B96">
<Clock size={16} />
<Text fz="12.5px" fw={600} c="#2E5B96">
Review pending
</Text>
</Group>
<Badge
variant="light"
color="edr-blue"
radius="sm"
size="lg"
leftSection={<Clock size={14} />}
>
Review pending
</Badge>
)}
</Group>
<Stack gap={12}>
{customerDocs.map((doc) => (
<DocReviewRow
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
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>
<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 radius="md" p="lg">
<Text fw={700} c="#10202F" mb="md">
Customs output documents
</Text>
<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="#2E5B96" />
<Text fz="13px" c="#10202F" truncate>
<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 ? (
<a href={doc.file.url} target="_blank" rel="noreferrer">
<Download size={15} />
</a>
<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="#9AA8B5">
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
@@ -300,25 +517,74 @@ function ClearanceReviewPanel({
</Alert>
)}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
{/* ── 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 DocReviewRow({
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,
@@ -326,74 +592,174 @@ function DocReviewRow({
}: {
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 (
<Box className="rounded-xl" style={{ border: "1px solid #E6ECF2", padding: 12 }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={18} color="#2E5B96" />
<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="13.5px" fw={600} c="#10202F" truncate>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="dimmed" truncate>
{doc.file ? doc.file.name : "Not uploaded"}
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{doc.reviewStatus === "APPROVED" && (
<Text fz="12px" fw={600} c="#0A6F4D">
Approved
</Text>
)}
{doc.reviewStatus === "QUERIED" && (
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
)}
{doc.file && (
<a href={doc.file.url} target="_blank" rel="noreferrer">
<Download size={15} />
</a>
<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>
{doc.file && (
<Group gap={8} mt={10} align="flex-end" wrap="nowrap">
<TextInput
placeholder="Query note (required to query)"
value={note}
onChange={(e) => onNote(e.currentTarget.value)}
style={{ flex: 1 }}
radius="md"
size="xs"
/>
<Button
size="compact-sm"
variant="light"
color="red"
disabled={busy || !note.trim()}
onClick={onQuery}
>
Query
</Button>
<Button
size="compact-sm"
color="edr-green"
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
</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>
)}
</Box>
{/* 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

@@ -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",

View File

@@ -209,6 +209,17 @@ 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`, {