mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
fix conflict
This commit is contained in:
@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isClearanceNavAction,
|
||||
isContractNavAction,
|
||||
listRowHasActions,
|
||||
type BookingActionContext,
|
||||
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
|
||||
const goToContract = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||
|
||||
const goToClearanceTab = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
|
||||
|
||||
const handleAction = (action: (typeof actions)[number]) => {
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else if (isClearanceNavAction(action.id)) {
|
||||
goToClearanceTab();
|
||||
} else if (isAllocateAction(action.id)) {
|
||||
onAllocateBooking?.();
|
||||
} else {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
Train,
|
||||
Wallet,
|
||||
XCircle,
|
||||
@@ -21,7 +23,9 @@ const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
||||
payment: <Wallet size={17} strokeWidth={1.85} />,
|
||||
ops_review: <ClipboardList size={17} strokeWidth={1.85} />,
|
||||
operations: <Train size={17} strokeWidth={1.85} />,
|
||||
completed: <CheckCircle size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Train, MapPin, ArrowRight } from "lucide-react";
|
||||
import { Train, MapPin, ArrowRight, FileText } from "lucide-react";
|
||||
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -44,6 +44,8 @@ export function BookingRouteServiceCard({
|
||||
const serviceLabel =
|
||||
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
|
||||
|
||||
const includesCustoms = booking.serviceType?.includesCustoms;
|
||||
|
||||
const metrics = [
|
||||
{ label: "Trade direction", value: booking.tradeDirection },
|
||||
{ label: "Freight type", value: booking.freightType },
|
||||
@@ -96,6 +98,47 @@ export function BookingRouteServiceCard({
|
||||
<MetricTile key={m.label} label={m.label} value={m.value} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{includesCustoms ? (
|
||||
<Box
|
||||
mt="md"
|
||||
px={14}
|
||||
py={10}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1.5px solid #CDEBDD",
|
||||
background: "#F6FBF8",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<FileText size={15} color="#0A6F4D" />
|
||||
<Text fz={13} fw={600} c="#0A6F4D">
|
||||
Customs clearing included automatically
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
) : booking.customsClearingAgent ? (
|
||||
<Box
|
||||
mt="md"
|
||||
px={14}
|
||||
py={10}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1.5px solid #E6ECF2",
|
||||
background: "#F8FAFC",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<FileText size={15} color="#64748B" />
|
||||
<Text fz={13} fw={500} c="#374151">
|
||||
Customs clearing agent:{" "}
|
||||
<Text component="span" fw={700} c="#10202F">
|
||||
{booking.customsClearingAgent}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export interface ClearanceReviewSectionProps {
|
||||
bookingId: string;
|
||||
/** Called after any review/finalize mutation so the parent can refetch. */
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
hideSummary?: boolean;
|
||||
}
|
||||
|
||||
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: "gray" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Staff-facing clearance document review: approve / query each customer
|
||||
* document, upload customs output documents (customs bookings only) and
|
||||
* finalize once every required document is approved. Shared by the Global
|
||||
* Logistics clearance detail page (customs) and the Marketing booking detail
|
||||
* (non-customs) — the only difference is the output-docs block, which renders
|
||||
* only when the booking has a customs output set.
|
||||
*/
|
||||
export function ClearanceReviewSection({
|
||||
bookingId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
}: ClearanceReviewSectionProps) {
|
||||
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: ["clearance", bookingId],
|
||||
queryFn: () => bookingsService.getClearance(bookingId),
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ["clearance", bookingId] });
|
||||
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
|
||||
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],
|
||||
);
|
||||
|
||||
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;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [customerDocs]);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Customer documents"
|
||||
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||
extra={
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
{!hideSummary && stats.total > 0 && (
|
||||
<Box>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb={6}
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="gray" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer documents are required for this booking.
|
||||
</Text>
|
||||
) : (
|
||||
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>
|
||||
</SectionCard>
|
||||
|
||||
{clearance.outputCode && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="Customs output documents"
|
||||
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
|
||||
accent="edr-green"
|
||||
>
|
||||
<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-green-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-green"
|
||||
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>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{finalizeMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{finalizeMutation.error instanceof Error
|
||||
? finalizeMutation.error.message
|
||||
: "Could not finalize clearance."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
<FileCheck2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved — you can finalize."
|
||||
: "Approve every required document to unlock finalization."}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeMutation.isPending}
|
||||
onClick={() => finalizeMutation.mutate()}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
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-green" : "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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageCheck } from "lucide-react";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
useContractOrders,
|
||||
useContractPool,
|
||||
} from "@/hooks/bookings/useContractOrders";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ContractOrdersPanelProps {
|
||||
/** The general-contract booking whose drawdown orders are listed. */
|
||||
contractBookingId: string;
|
||||
/** Whether the contract is container-based (affects quantity labels). */
|
||||
isContainer: boolean;
|
||||
}
|
||||
|
||||
/** Format a contracted/remaining quantity with its unit. */
|
||||
function formatQuantity(
|
||||
qty: number,
|
||||
unit: Freight.ContractQuantityLine["unitOfMeasure"],
|
||||
isContainerLine: boolean,
|
||||
): string {
|
||||
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
|
||||
if (isContainerLine) return `${rounded} containers`;
|
||||
if (unit === "PER_ITEM") return `${rounded} items`;
|
||||
return `${rounded} tons`;
|
||||
}
|
||||
|
||||
/** Summarise an order's lines, e.g. "2 20FT, 1 40FT" or "15". */
|
||||
function summariseLines(lines: Freight.IBookingOrderLine[]): string {
|
||||
return lines
|
||||
.map((l) => {
|
||||
const qty = Number(l.quantity);
|
||||
const label = Number.isInteger(qty) ? `${qty}` : qty.toFixed(2);
|
||||
return `${label}${l.containerTypeName ? ` ${l.containerTypeName}` : ""}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice "Orders" tab for a general contract: shows the drawdown pool and
|
||||
* lists each order placed against the contract. Each order links to its child
|
||||
* booking's detail page, where staff approve it and review clearance/customer
|
||||
* documents independently (same screen as a one-time booking).
|
||||
*/
|
||||
export function ContractOrdersPanel({
|
||||
contractBookingId,
|
||||
isContainer,
|
||||
}: ContractOrdersPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: orders, isLoading: ordersLoading } =
|
||||
useContractOrders(contractBookingId);
|
||||
const { data: pool, isLoading: poolLoading } =
|
||||
useContractPool(contractBookingId);
|
||||
|
||||
const poolLines = pool ?? [];
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const contracted = poolLines.reduce(
|
||||
(s, l) => s + (l.contractedQuantity || 0),
|
||||
0,
|
||||
);
|
||||
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
|
||||
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
|
||||
return { contracted, ordered, pct };
|
||||
}, [poolLines]);
|
||||
|
||||
if (ordersLoading || poolLoading) {
|
||||
return (
|
||||
<Center mih={240}>
|
||||
<Loader color="gray" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Drawdown pool */}
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Contracted quantity
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
How much of this contract has been ordered versus what remains.
|
||||
</Text>
|
||||
</Box>
|
||||
{totals.contracted > 0 && (
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={[{ value: totals.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={800}>
|
||||
{totals.pct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{poolLines.length === 0 && (
|
||||
<Text fz={13} c="dimmed">
|
||||
No quantity pool available.
|
||||
</Text>
|
||||
)}
|
||||
{poolLines.map((line, i) => {
|
||||
const pct =
|
||||
line.contractedQuantity > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(line.orderedQuantity / line.contractedQuantity) * 100,
|
||||
)
|
||||
: 0;
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
const depleted = line.remainingQuantity <= 0;
|
||||
return (
|
||||
<div key={line.containerTypeId ?? `bulk-${i}`}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={8} align="center">
|
||||
<Text fz={14} fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{depleted && (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Fully ordered
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text span fw={700} c={depleted ? "dimmed" : "edr-green"}>
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
</Text>{" "}
|
||||
remaining of{" "}
|
||||
{formatQuantity(
|
||||
line.contractedQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
color={depleted ? "gray" : "edr-green"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Orders */}
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={700} fz={16}>
|
||||
Orders
|
||||
</Text>
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{orders?.length ?? 0}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!orders || orders.length === 0 ? (
|
||||
<Stack align="center" gap={8} py="xl">
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz={13} c="dimmed" ta="center" maw={360}>
|
||||
No orders have been placed against this contract yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{orders.map((order) => {
|
||||
const childId = order.bookingId;
|
||||
const clickable = Boolean(childId);
|
||||
return (
|
||||
<Group
|
||||
key={order.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: clickable ? "pointer" : "default",
|
||||
}}
|
||||
onClick={
|
||||
clickable
|
||||
? () =>
|
||||
navigate(`/dashboard/booking-requests/${childId}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="violet"
|
||||
>
|
||||
<PackageCheck size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} truncate>
|
||||
{order.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship{" "}
|
||||
{new Date(order.scheduledDate).toLocaleDateString()}
|
||||
{order.lines.length > 0
|
||||
? ` · ${summariseLines(order.lines)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<BookingStatusBadge status={order.status} />
|
||||
{clickable && (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
export * from "./BookingDetailHeader";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Radio,
|
||||
RingProgress,
|
||||
@@ -107,7 +108,7 @@ export function AllocateBookingWizard({
|
||||
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const scheduleDate = booking.scheduledDate;
|
||||
const [locomotiveId, setLocomotiveId] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
@@ -152,7 +153,7 @@ export function AllocateBookingWizard({
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleMode === "new") {
|
||||
setLocomotiveId("");
|
||||
setLocomotiveIds([]);
|
||||
}
|
||||
}, [routeId, scheduleMode]);
|
||||
|
||||
@@ -264,11 +265,11 @@ export function AllocateBookingWizard({
|
||||
|
||||
const ensureSchedule = async (): Promise<string> => {
|
||||
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
throw new Error("Select route, date, and locomotive");
|
||||
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
|
||||
throw new Error("Select route, date, and at least two locomotives");
|
||||
}
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveId },
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
});
|
||||
setSelectedScheduleId(created.id);
|
||||
return created.id;
|
||||
@@ -526,17 +527,25 @@ export function AllocateBookingWizard({
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
description="At least two (front and back)"
|
||||
placeholder={
|
||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||
}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user