mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge pull request #330 from Tria-plc/freight_feature/contrat
Freight feature/contrat
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
Boxes,
|
||||
Building2,
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
@@ -28,9 +29,14 @@ 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 DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -98,6 +104,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Contract requests",
|
||||
href: "/dashboard/contract-requests",
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -117,9 +129,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
items: [
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
@@ -399,19 +411,76 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
{/* Legacy booking-based clearance URLs → the contract clearance hub. */}
|
||||
<Route
|
||||
path="clearance"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="clearance/:id"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
/>
|
||||
|
||||
{/* Contracts (Path A/B) */}
|
||||
<Route
|
||||
path="contract-requests"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||
<DocumentClearanceListPage />
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
|
||||
<ContractRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="clearance/:id"
|
||||
path="contract-requests/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||
<DocumentClearanceDetailPage />
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
|
||||
<ContractRequestDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-requests/:id/view"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
|
||||
<ContractViewPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<ContractClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/:id/create-booking"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<GlCreateBookingForm />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:id/milestones"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<BookingMilestonesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canManageScheduling } from "@/lib/permissions";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
@@ -21,11 +16,8 @@ interface BookingActionsToolbarProps {
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const { user } = useAuth();
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const canAllocate = canManageScheduling(user);
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
@@ -106,11 +98,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu
|
||||
row={row}
|
||||
variant="toolbar"
|
||||
onAllocateBooking={() => setAllocateOpen(true)}
|
||||
/>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -130,14 +118,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocate && canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => setAllocateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
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 { Banknote, Receipt } from "lucide-react";
|
||||
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
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 qc = useQueryClient();
|
||||
const computed = Number(booking.totalAmount);
|
||||
// The booking price is computed from the contract and is NOT staff-editable.
|
||||
// A historical `adjustedTotalAmount` (from before adjustments were removed)
|
||||
// is still shown read-only so old records render correctly.
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
@@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
|
||||
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 })}`;
|
||||
|
||||
@@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
@@ -28,9 +28,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
export interface ClearanceReviewSectionProps {
|
||||
bookingId: string;
|
||||
@@ -65,7 +68,8 @@ export function ClearanceReviewSection({
|
||||
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 [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: ["clearance", bookingId],
|
||||
@@ -96,13 +100,17 @@ export function ClearanceReviewSection({
|
||||
});
|
||||
|
||||
const outputMutation = useMutation({
|
||||
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||
mutationFn: (files: Record<string, File>) =>
|
||||
bookingsService.uploadClearanceOutput(bookingId, files),
|
||||
onSuccess: () => {
|
||||
toast.success("Output documents uploaded");
|
||||
setOutputFiles({});
|
||||
toast.success("Document uploaded");
|
||||
setUploadingKey(null);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Upload failed"),
|
||||
onError: () => {
|
||||
toast.error("Upload failed");
|
||||
setUploadingKey(null);
|
||||
},
|
||||
});
|
||||
|
||||
const finalizeMutation = useMutation({
|
||||
@@ -207,6 +215,7 @@ export function ClearanceReviewSection({
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
@@ -218,73 +227,100 @@ export function ClearanceReviewSection({
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="Customs output documents"
|
||||
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
|
||||
subtitle="Upload each document individually — changes save immediately."
|
||||
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
|
||||
{glDocs.map((doc) => {
|
||||
const isUploading =
|
||||
uploadingKey === doc.fileKey && outputMutation.isPending;
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
</FileButton>
|
||||
<FileButton
|
||||
onChange={(f) => {
|
||||
if (!f) return;
|
||||
setUploadingKey(doc.fileKey);
|
||||
outputMutation.mutate({ [doc.fileKey]: f });
|
||||
}}
|
||||
accept="application/pdf,image/*"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
isUploading ? (
|
||||
<Loader size={12} color="edr-green" />
|
||||
) : (
|
||||
<Upload size={13} />
|
||||
)
|
||||
}
|
||||
loading={isUploading}
|
||||
>
|
||||
{doc.file ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -325,6 +361,7 @@ export function ClearanceReviewSection({
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -366,6 +403,7 @@ function DocReviewCard({
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
@@ -375,6 +413,7 @@ function DocReviewCard({
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
@@ -420,22 +459,28 @@ function DocReviewCard({
|
||||
<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>
|
||||
)}
|
||||
{hasFile &&
|
||||
isViewable({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
}) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ export interface BookingContainerView {
|
||||
containerType?: {
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
isReefer?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,6 @@ 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,
|
||||
@@ -93,15 +86,6 @@ export function useBookingActionDialog(
|
||||
{ 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;
|
||||
@@ -151,8 +135,7 @@ export function useBookingActionDialog(
|
||||
(pendingAction?.input === "file" && !selectedFile) ||
|
||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
|
||||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
|
||||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
|
||||
|
||||
return {
|
||||
actions,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Check, Circle, Clock, MinusCircle } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ClearanceMilestoneTimelineProps {
|
||||
milestones: Freight.IClearanceMilestone[];
|
||||
/** Complete a milestone by code (omit to render read-only). */
|
||||
onComplete?: (code: string, note?: string) => void;
|
||||
/** True while a complete mutation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.MilestoneStatus,
|
||||
{ color: string; label: string }
|
||||
> = {
|
||||
COMPLETED: { color: "edr-green", label: "Completed" },
|
||||
PENDING: { color: "gray", label: "Pending" },
|
||||
SKIPPED: { color: "gray", label: "Skipped" },
|
||||
};
|
||||
|
||||
/** Vertical timeline of GL clearance milestones with inline complete actions. */
|
||||
export function ClearanceMilestoneTimeline({
|
||||
milestones,
|
||||
onComplete,
|
||||
busy,
|
||||
}: ClearanceMilestoneTimelineProps) {
|
||||
const [openNote, setOpenNote] = useState<Record<string, boolean>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const nextPending = sorted.find((m) => m.status === "PENDING");
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No milestones for this shipment yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{sorted.map((m, index) => {
|
||||
const isLast = index === sorted.length - 1;
|
||||
const meta = STATUS_META[m.status];
|
||||
const isNext = nextPending?.id === m.id;
|
||||
const Icon =
|
||||
m.status === "COMPLETED"
|
||||
? Check
|
||||
: m.status === "SKIPPED"
|
||||
? MinusCircle
|
||||
: isNext
|
||||
? Clock
|
||||
: Circle;
|
||||
|
||||
return (
|
||||
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
|
||||
<ThemeIcon
|
||||
variant={m.status === "COMPLETED" ? "filled" : "light"}
|
||||
color={isNext ? "edr-green" : meta.color}
|
||||
radius="xl"
|
||||
size={30}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} />
|
||||
</ThemeIcon>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 28,
|
||||
background:
|
||||
m.status === "COMPLETED"
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box pb={isLast ? 0 : "md"} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{m.milestoneLabel}
|
||||
</Text>
|
||||
<Group gap={6} mt={2} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{m.ownerRegion ? (
|
||||
<Badge size="xs" variant="default">
|
||||
{m.ownerRegion}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{m.note ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{m.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{onComplete && m.status === "PENDING" && isNext ? (
|
||||
!openNote[m.milestoneCode] ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
) : null
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{onComplete && openNote[m.milestoneCode] && (
|
||||
<Box mt="xs">
|
||||
<Textarea
|
||||
placeholder="Optional note for this milestone…"
|
||||
value={notes[m.milestoneCode] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((n) => ({
|
||||
...n,
|
||||
[m.milestoneCode]: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
autosize
|
||||
minRows={2}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
loading={busy}
|
||||
onClick={() => {
|
||||
onComplete(
|
||||
m.milestoneCode,
|
||||
notes[m.milestoneCode]?.trim() || undefined,
|
||||
);
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Mark complete
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractActionsToolbarProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
/** Switch the detail page to its Clearance Review tab. */
|
||||
onReviewClearance?: () => void;
|
||||
}
|
||||
|
||||
// Contract is in the pre-booking clearance phase — staff can review the
|
||||
// customer's uploaded documents.
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
||||
export function ContractActionsToolbar({
|
||||
contract,
|
||||
mutations,
|
||||
onReviewClearance,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { status } = contract;
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// Admin-configured validity durations (days) for the accept dialog. Staff can
|
||||
// only pick one of these — no free-typing. Read-only setting, fetched once.
|
||||
const { data: validitySetting, isLoading: validityLoading } = useQuery({
|
||||
...api.dropdownSettings.getByCode.queryOptions({
|
||||
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
|
||||
}),
|
||||
retry: false,
|
||||
});
|
||||
const validityOptions = useMemo(
|
||||
() =>
|
||||
[...(validitySetting?.children ?? [])]
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((o) => ({ value: String(o.value), label: o.label })),
|
||||
[validitySetting],
|
||||
);
|
||||
// Default the selection to the first configured option when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (acceptOpen && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [acceptOpen, validityDays, validityOptions]);
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// Generation only becomes available once EVERY approval step is complete and
|
||||
// the contract reaches APPROVED. While any step is still pending the contract
|
||||
// stays in PENDING_APPROVAL, so this button does not appear after only the
|
||||
// first (line-staff) approval — the director step must land first.
|
||||
const needsManualGenerate =
|
||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
// generated contract before signing) — no sign button in this toolbar.
|
||||
const canViewContract =
|
||||
["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status) &&
|
||||
Boolean(contract.contractGeneratedAt);
|
||||
// Show "Review clearance" while the contract is in the document-review phase.
|
||||
// Reviewer = GL (Path B / customs) or Operations (Path A / no customs).
|
||||
const canReviewClearance =
|
||||
Boolean(onReviewClearance) &&
|
||||
CLEARANCE_REVIEW_STATUSES.includes(status);
|
||||
const clearanceReviewer = contract.customsClearingEnabled
|
||||
? "Review clearance (GL)"
|
||||
: "Review clearance (Ops)";
|
||||
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => setAcceptOpen(true)}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsManualGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Sparkles size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canViewContract && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${contract.id}/view`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canReviewClearance && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
onClick={onReviewClearance}
|
||||
>
|
||||
{clearanceReviewer}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* GL "Create booking" removed for now — clearance ends at finalize and
|
||||
the customer creates the booking in the portal. */}
|
||||
|
||||
{!canAccept &&
|
||||
!needsManualGenerate &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Accept — sets the contract validity window */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
onClose={() => setAcceptOpen(false)}
|
||||
title="Accept contract for approval"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick the contract validity window, then start the approval chain.
|
||||
</Text>
|
||||
{validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Validity"
|
||||
placeholder="Select a validity period"
|
||||
data={validityOptions}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="orange.7">
|
||||
{validityLoading
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under "}
|
||||
{!validityLoading && (
|
||||
<Anchor
|
||||
href="/dashboard/dropdown-settings"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/dashboard/dropdown-settings");
|
||||
}}
|
||||
>
|
||||
Dropdown Settings
|
||||
</Anchor>
|
||||
)}
|
||||
{!validityLoading && "."}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={mutations.staffAccept.isPending}
|
||||
disabled={!validityDays}
|
||||
onClick={() => {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
mutations.staffAccept.mutate(days, {
|
||||
onSuccess: () => setAcceptOpen(false),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
opened={changesOpen}
|
||||
onClose={() => setChangesOpen(false)}
|
||||
title="Request changes"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="What needs to change?"
|
||||
placeholder="Describe the changes the customer must make…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={changesNote}
|
||||
onChange={(e) => setChangesNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="orange"
|
||||
disabled={!changesNote.trim()}
|
||||
loading={mutations.requestChanges.isPending}
|
||||
onClick={() =>
|
||||
mutations.requestChanges.mutate(changesNote, {
|
||||
onSuccess: () => {
|
||||
setChangesOpen(false);
|
||||
setChangesNote("");
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Reject */}
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject contract"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="Explain why this contract is rejected…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectReason.trim()}
|
||||
loading={mutations.reject.isPending}
|
||||
onClick={() =>
|
||||
mutations.reject.mutate(rejectReason, {
|
||||
onSuccess: () => {
|
||||
setRejectOpen(false);
|
||||
setRejectReason("");
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import type { ContractListRow } from "@/features/contracts/mapContractListRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContractApprovalProgressCellProps {
|
||||
row: ContractListRow;
|
||||
}
|
||||
|
||||
export function ContractApprovalProgressCell({
|
||||
row,
|
||||
}: ContractApprovalProgressCellProps) {
|
||||
const summary = formatContractApprovalProgress(row.status, row.approvalSteps);
|
||||
|
||||
return (
|
||||
<div className="min-w-[8.5rem] py-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
summary.complete
|
||||
? "text-[color:var(--freight-brand)]"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{summary.label}
|
||||
</p>
|
||||
{summary.detail ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{summary.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractApprovalStepsCardProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Approval chain with inline approve on the next pending step. */
|
||||
export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(contract.approvalSteps ?? [])].sort(
|
||||
(a, b) => a.stepOrder - b.stepOrder,
|
||||
),
|
||||
[contract.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() =>
|
||||
mutations.approveStep.mutate({
|
||||
stepId: step.id,
|
||||
requiredRole: step.requiredRole,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "edr-green"
|
||||
: step.status === "REJECTED"
|
||||
? "red"
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: isNext
|
||||
? "var(--mantine-color-gray-7)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
{step.note && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } 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,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
export interface ContractClearanceReviewSectionProps {
|
||||
contractId: 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;
|
||||
/**
|
||||
* Path A (non-customs): the reviewer is Operations, not GL, and there is no GL
|
||||
* output upload step. Routes review/finalize to the Operations endpoints.
|
||||
*/
|
||||
selfClear?: boolean;
|
||||
/**
|
||||
* Clearance is finalized — render the document outcomes (approved / queried,
|
||||
* by whom, when) but hide all approve / query / finalize actions.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.ContractDocReviewStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
APPROVED: { label: "Approved", color: "edr-green" },
|
||||
QUERIED: { label: "Queried", color: "red" },
|
||||
PENDING: { label: "Pending", color: "gray" },
|
||||
};
|
||||
|
||||
function formatReviewedAt(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-booking clearance review for a CONTRACT. Approve / query each customer
|
||||
* document, upload GL output documents, and finalize once every required
|
||||
* document is approved. When `readOnly` it becomes an audit view: approved /
|
||||
* queried outcomes with reviewer + timestamp, no actions.
|
||||
*/
|
||||
export function ContractClearanceReviewSection({
|
||||
contractId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
selfClear = false,
|
||||
readOnly = false,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
queryFn: () => contractsService.getClearance(contractId),
|
||||
});
|
||||
|
||||
const { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance } =
|
||||
useContractClearanceMutations(contractId, selfClear);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
// GL output documents — anything not uploaded by the customer. The backend
|
||||
// tags these `uploadedBy: 'gl'`; matching on "not customer" keeps it robust if
|
||||
// that ever splits into gl_et / gl_dj.
|
||||
const glDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||
[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]);
|
||||
|
||||
// Documents with a file uploaded but not yet approved — "Approve all" targets.
|
||||
const approvableKeys = customerDocs
|
||||
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
||||
.map((d) => d.fileKey);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReview = (
|
||||
fileKey: string,
|
||||
status: "APPROVED" | "QUERIED",
|
||||
note?: string,
|
||||
) =>
|
||||
reviewDocument.mutate(
|
||||
{ fileKey, status, note },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (status === "QUERIED")
|
||||
setOpenQuery((o) => ({ ...o, [fileKey]: false }));
|
||||
onChanged?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Customer documents"
|
||||
subtitle={
|
||||
readOnly
|
||||
? `Reviewed by the ${reviewerTeam} team.`
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
}
|
||||
extra={
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
{!readOnly && approvableKeys.length > 0 && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<FileCheck2 size={14} />}
|
||||
loading={approveAll.isPending}
|
||||
disabled={reviewDocument.isPending}
|
||||
onClick={() => approveAll.mutate(approvableKeys)}
|
||||
>
|
||||
Approve all ({approvableKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<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 contract.
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
reviewerTeam={reviewerTeam}
|
||||
readOnly={readOnly}
|
||||
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={() => handleReview(doc.fileKey, "APPROVED")}
|
||||
onQuery={() =>
|
||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewDocument.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{glDocs.length > 0 && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="GL output documents"
|
||||
subtitle="Upload each document individually — changes save immediately."
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{glDocs.map((doc) => {
|
||||
const isUploading =
|
||||
uploadingKey === doc.fileKey && uploadOutputDocuments.isPending;
|
||||
return (
|
||||
<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 ? (
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<FileButton
|
||||
onChange={(f) => {
|
||||
if (!f) return;
|
||||
setUploadingKey(doc.fileKey);
|
||||
uploadOutputDocuments.mutate(
|
||||
{ [doc.fileKey]: f },
|
||||
{ onSuccess: () => { setUploadingKey(null); onChanged?.(); },
|
||||
onError: () => setUploadingKey(null) },
|
||||
);
|
||||
}}
|
||||
accept="application/pdf,image/*"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
isUploading ? (
|
||||
<Loader size={12} color="edr-green" />
|
||||
) : (
|
||||
<Upload size={13} />
|
||||
)
|
||||
}
|
||||
loading={isUploading}
|
||||
>
|
||||
{doc.file ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!readOnly && finalizeClearance.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{finalizeClearance.error instanceof Error
|
||||
? finalizeClearance.error.message
|
||||
: "Could not finalize clearance."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{readOnly ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-edr-green-3)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 70%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
||||
<CheckCircle2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Clearance was finalized by the {reviewerTeam} team. This is a
|
||||
read-only record of the approved documents.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<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={finalizeClearance.isPending}
|
||||
onClick={() =>
|
||||
finalizeClearance.mutate(undefined, {
|
||||
onSuccess: () => onChanged?.(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
{viewer}
|
||||
</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,
|
||||
reviewerTeam,
|
||||
readOnly,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
reviewerTeam: string;
|
||||
readOnly: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const hasFile = !!doc.file;
|
||||
const isApproved = status === "APPROVED";
|
||||
const isQueried = status === "QUERIED";
|
||||
const reviewedAt = formatReviewedAt(doc.reviewedAt);
|
||||
|
||||
// Approved cards get a light green gradient + green border so the outcome is
|
||||
// instantly scannable; queried cards get a soft red; pending stay neutral.
|
||||
const cardStyle = isApproved
|
||||
? {
|
||||
borderColor: "var(--mantine-color-edr-green-3)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 72%)",
|
||||
}
|
||||
: isQueried
|
||||
? {
|
||||
borderColor: "var(--mantine-color-red-2)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-red-0) 0%, #FFFFFF 78%)",
|
||||
}
|
||||
: { borderColor: "var(--mantine-color-edr-border-6)" };
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" style={cardStyle}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isApproved ? "edr-green" : isQueried ? "red" : "gray"}
|
||||
radius="md"
|
||||
size={40}
|
||||
>
|
||||
{isApproved ? <CheckCircle2 size={19} /> : <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>
|
||||
{isApproved && (reviewedAt || reviewerTeam) && (
|
||||
<Group gap={5} wrap="nowrap" mt={3}>
|
||||
<UserCheck size={12} color="var(--mantine-color-edr-green-7)" />
|
||||
<Text fz="11.5px" c="edr-green.8" fw={600} truncate>
|
||||
Approved by {reviewerTeam}
|
||||
{reviewedAt ? ` · ${reviewedAt}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={meta.color}
|
||||
radius="sm"
|
||||
leftSection={
|
||||
isApproved ? (
|
||||
<CheckCircle2 size={11} />
|
||||
) : isQueried ? (
|
||||
<MessageSquareWarning size={11} />
|
||||
) : (
|
||||
<Clock size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile &&
|
||||
isViewable({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
}) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{isQueried && 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>
|
||||
)}
|
||||
|
||||
{!readOnly && hasFile && !isApproved && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
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."
|
||||
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="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
loading={busy}
|
||||
disabled={!note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Send query to customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Repeat } from "lucide-react";
|
||||
|
||||
import {
|
||||
CONTRACT_STATUS_COLOR,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
interface ContractStatusBadgeProps {
|
||||
status: string;
|
||||
/** When the contract is a renewal of a prior one, show a sibling badge. */
|
||||
isRenewal?: boolean;
|
||||
}
|
||||
|
||||
export function ContractStatusBadge({
|
||||
status,
|
||||
isRenewal,
|
||||
}: ContractStatusBadgeProps) {
|
||||
const style = CONTRACT_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "gray",
|
||||
};
|
||||
const color = CONTRACT_STATUS_COLOR[status] ?? "gray";
|
||||
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
title={style.label}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!isRenewal) return statusBadge;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{statusBadge}
|
||||
<Badge
|
||||
color="indigo"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={<Repeat size={12} />}
|
||||
title="Renewal of a prior contract"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Renewal
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
||||
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} />,
|
||||
active: <Truck size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
};
|
||||
|
||||
interface ContractStatusTabsProps {
|
||||
active: ContractStatusTabKey;
|
||||
onChange: (tab: ContractStatusTabKey) => void;
|
||||
counts?: Partial<Record<ContractStatusTabKey, number>>;
|
||||
}
|
||||
|
||||
export function ContractStatusTabs({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
}: ContractStatusTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
||||
{CONTRACT_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
leftSection={TAB_ICONS[tab.key]}
|
||||
size={"sm"}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ContractStatusTabKey };
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||
|
||||
import {
|
||||
CONTRACT_WORKFLOW_STAGES,
|
||||
getContractWorkflowStageIndex,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
BRAND_GREEN,
|
||||
detailStyles,
|
||||
} from "@/components/bookings/detail/booking-detail.styles";
|
||||
|
||||
const STAGE_ICONS: LucideIcon[] = [
|
||||
FileText,
|
||||
FileSignature,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Check,
|
||||
];
|
||||
|
||||
interface ContractWorkflowStepperProps {
|
||||
status: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ContractWorkflowStepper({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
}: ContractWorkflowStepperProps) {
|
||||
const currentStage = getContractWorkflowStageIndex(status);
|
||||
const isTerminal = currentStage < 0;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Workflow} title="Workflow progress">
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||
{CONTRACT_WORKFLOW_STAGES.map((stage, index) => {
|
||||
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||
const isComplete = !isTerminal && index < currentStage;
|
||||
const isActive = !isTerminal && index === currentStage;
|
||||
const isLast = index === CONTRACT_WORKFLOW_STAGES.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={stage.label}
|
||||
style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}
|
||||
>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-2)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check size={16} strokeWidth={3} />
|
||||
) : (
|
||||
<Icon size={15} />
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{stage.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 8,
|
||||
marginBottom: 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={
|
||||
isTerminal
|
||||
? detailStyles.statusBannerTerminal
|
||||
: detailStyles.statusBanner
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
FileText,
|
||||
Package,
|
||||
Plus,
|
||||
Receipt,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { OperationDatePicker } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
computeGlShipmentTotal,
|
||||
formatRateUnit,
|
||||
type GlShipmentQuantities,
|
||||
} from "./gl-booking-form/total";
|
||||
|
||||
interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | string;
|
||||
}
|
||||
|
||||
interface ContainerLineDraft {
|
||||
containerSize: string;
|
||||
hazardousQuantity: number | string;
|
||||
reeferQuantity: number | string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
interface BulkLineDraft {
|
||||
cargoTypeId: string;
|
||||
cargoWeightTons: number | string;
|
||||
itemCount: number | string;
|
||||
hazardousQuantity: number | string;
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
}
|
||||
|
||||
export default function GlCreateBookingForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
// When GL accepts a shipment request, the form opens with ?requestId=… so it
|
||||
// can prefill the requested quantities/date and mark the request accepted on
|
||||
// success.
|
||||
const requestId = searchParams.get("requestId");
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const { data: capacity = [] } = useContractCapacity(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
|
||||
const { data: bookingRequest } = useQuery({
|
||||
queryKey: ["shipment-request", requestId],
|
||||
queryFn: () => contractsService.getBookingRequest(requestId!),
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||
const [prefilled, setPrefilled] = useState(false);
|
||||
|
||||
// Prefill once from an accepted shipment request: size/qty container lines
|
||||
// (one blank unit per requested container) + bulk + route + notes. GL still
|
||||
// enters per-unit container numbers + sets the binding shipment date.
|
||||
useEffect(() => {
|
||||
if (!bookingRequest || prefilled) return;
|
||||
setPrefilled(true);
|
||||
const lines = bookingRequest.requestedLines ?? {};
|
||||
if (lines.containers?.length) {
|
||||
setContainerLines(
|
||||
lines.containers.map((c) => ({
|
||||
containerSize: c.containerSize,
|
||||
hazardousQuantity: c.hazardousQuantity ?? "",
|
||||
reeferQuantity: c.reeferQuantity ?? "",
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
|
||||
emptyUnit(),
|
||||
),
|
||||
})),
|
||||
);
|
||||
} else if (lines.bulk) {
|
||||
setBulkLines([
|
||||
{
|
||||
cargoTypeId: lines.bulk.cargoTypeId ?? "",
|
||||
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
|
||||
itemCount: lines.bulk.itemCount ?? "",
|
||||
hazardousQuantity: lines.bulk.hazardousQuantity ?? "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (bookingRequest.contractRouteId)
|
||||
setContractRouteId(bookingRequest.contractRouteId);
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
// Price-confirm modal — GL reviews the estimate before booking on behalf of
|
||||
// the customer, mirroring the portal customer flow.
|
||||
const [priceOpen, setPriceOpen] = useState(false);
|
||||
|
||||
const isContainer = contract?.freightType === "CONTAINER";
|
||||
const routes = useMemo(
|
||||
() =>
|
||||
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[contract?.routes],
|
||||
);
|
||||
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
|
||||
|
||||
const containerSizes = useMemo(() => {
|
||||
const sizes = new Set<string>();
|
||||
(contract?.cargoScope ?? []).forEach((s) => {
|
||||
if (s.containerSize) sizes.add(s.containerSize);
|
||||
});
|
||||
return [...sizes];
|
||||
}, [contract?.cargoScope]);
|
||||
|
||||
// Bulk cargo types declared on the contract scope (prefill, no free-text).
|
||||
const bulkCargoOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
(contract?.cargoScope ?? []).forEach((s) => {
|
||||
if (s.containerSize || !s.cargoTypeId) return;
|
||||
if (!seen.has(s.cargoTypeId)) {
|
||||
seen.set(s.cargoTypeId, s.cargoFreeText?.trim() || s.cargoTypeId);
|
||||
}
|
||||
});
|
||||
return [...seen.entries()].map(([value, label]) => ({ value, label }));
|
||||
}, [contract?.cargoScope]);
|
||||
|
||||
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
|
||||
|
||||
// Normalized quantities for the client-side price estimate (same source the
|
||||
// portal customer sees: the contract's frozen unit rates × entered qty).
|
||||
const quantities: GlShipmentQuantities = useMemo(
|
||||
() => ({
|
||||
isContainer,
|
||||
containers: containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||
})),
|
||||
bulkQuantity: bulkLines.reduce(
|
||||
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
|
||||
0,
|
||||
),
|
||||
bulkHazardousQuantity: bulkLines.reduce(
|
||||
(s, l) => s + Number(l.hazardousQuantity || 0),
|
||||
0,
|
||||
),
|
||||
}),
|
||||
[isContainer, containerLines, bulkLines],
|
||||
);
|
||||
|
||||
const priceTotal = useMemo(
|
||||
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
|
||||
[contract, quantities],
|
||||
);
|
||||
|
||||
// The route this shipment ships on (for the cargo-aware day list). For a
|
||||
// single-route contract there's exactly one; for GENERAL multi-route, the
|
||||
// selected route (defaults to the first).
|
||||
const selectedRoute = useMemo(
|
||||
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
|
||||
[routes, contractRouteId],
|
||||
);
|
||||
|
||||
// Cargo-aware availability query: only days where a train has remaining
|
||||
// capacity AND enough matching-type wagons for the entered cargo. Null until
|
||||
// the cargo is entered (so the Schedule section stays empty first).
|
||||
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
|
||||
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
|
||||
return null;
|
||||
if (isContainer) {
|
||||
const containers = containerLines
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
}))
|
||||
.filter((c) => c.quantity >= 1);
|
||||
if (containers.length === 0) return null;
|
||||
return {
|
||||
originYardId: selectedRoute.originYardId,
|
||||
destinationYardId: selectedRoute.destinationYardId,
|
||||
freightType: "CONTAINER",
|
||||
containers,
|
||||
};
|
||||
}
|
||||
const tons = bulkLines.reduce(
|
||||
(s, l) => s + Number(l.cargoWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
if (tons <= 0) return null;
|
||||
return {
|
||||
originYardId: selectedRoute.originYardId,
|
||||
destinationYardId: selectedRoute.destinationYardId,
|
||||
freightType: "BULK",
|
||||
cargoTypeCode:
|
||||
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
|
||||
?.cargoTypeCode ?? undefined,
|
||||
totalWeightTons: tons,
|
||||
};
|
||||
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
|
||||
|
||||
const { data: availableDays, isLoading: daysLoading } = useQuery({
|
||||
...api.trainScheduling.availableDaysForCargo.queryOptions({
|
||||
input: cargoQuery ?? {
|
||||
freightType: "BULK" as const,
|
||||
},
|
||||
}),
|
||||
enabled: cargoQuery !== null,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="50vh">
|
||||
<Loader color="gray" />
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Container line helpers ──
|
||||
const addContainerLine = () =>
|
||||
setContainerLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
containerSize: containerSizes[0] ?? "20ft",
|
||||
hazardousQuantity: "",
|
||||
reeferQuantity: "",
|
||||
units: [emptyUnit()],
|
||||
},
|
||||
]);
|
||||
const removeContainerLine = (idx: number) =>
|
||||
setContainerLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
const addUnit = (lineIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: [...containerLines[lineIdx].units, emptyUnit()],
|
||||
});
|
||||
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx),
|
||||
});
|
||||
const patchUnit = (
|
||||
lineIdx: number,
|
||||
unitIdx: number,
|
||||
patch: Partial<UnitDraft>,
|
||||
) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.map((u, i) =>
|
||||
i === unitIdx ? { ...u, ...patch } : u,
|
||||
),
|
||||
});
|
||||
|
||||
// ── Bulk line helpers ──
|
||||
const addBulkLine = () =>
|
||||
setBulkLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
cargoTypeId: defaultBulkCargoTypeId,
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
hazardousQuantity: "",
|
||||
},
|
||||
]);
|
||||
const removeBulkLine = (idx: number) =>
|
||||
setBulkLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
|
||||
setBulkLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(scheduledDate) &&
|
||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||
(isContainer ? containerLines.length > 0 : bulkLines.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!scheduledDate) return;
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
...(contractRouteId ? { contractRouteId } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
payload.containers = containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
...(l.reeferQuantity !== ""
|
||||
? { reeferQuantity: Number(l.reeferQuantity) }
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
payload.bulkLines = bulkLines.map((l) => ({
|
||||
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
|
||||
...(l.cargoWeightTons !== ""
|
||||
? { cargoWeightTons: Number(l.cargoWeightTons) }
|
||||
: {}),
|
||||
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
mutations.createBooking.mutate(payload, {
|
||||
onSuccess: async (booking) => {
|
||||
if (requestId) {
|
||||
// GENERAL+customs accept flow: mark the request accepted + link the
|
||||
// booking, then hand off to the per-booking clearance review.
|
||||
try {
|
||||
await contractsService.acceptBookingRequest(requestId, booking.id);
|
||||
} catch {
|
||||
// Non-fatal — the booking exists; the request link can be retried.
|
||||
}
|
||||
navigate(`/dashboard/clearance/${booking.id}`);
|
||||
} else {
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Create booking (GL)"
|
||||
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
||||
backTo={`/dashboard/contracts/clearance/${contract.id}`}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{
|
||||
label: contract.reference,
|
||||
href: `/dashboard/contracts/clearance/${contract.id}`,
|
||||
},
|
||||
{ label: "Create booking" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{capacity.length > 0 && (
|
||||
<Alert
|
||||
color={capacity.every((c) => c.remaining === 0) ? "red" : "blue"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Boxes size={16} />}
|
||||
title="Contract draw-down capacity"
|
||||
>
|
||||
<Group gap={8} wrap="wrap">
|
||||
{capacity.map((c, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
color={c.remaining === 0 ? "red" : "blue"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
{bookingRequest ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<FileText size={16} />}
|
||||
title="From shipment request"
|
||||
>
|
||||
Booking on behalf of the customer for request{" "}
|
||||
<b>{bookingRequest.reference}</b>.
|
||||
{bookingRequest.scheduledDate ? (
|
||||
<>
|
||||
{" "}
|
||||
Customer requested{" "}
|
||||
<b>
|
||||
{new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(bookingRequest.scheduledDate))}
|
||||
</b>{" "}
|
||||
— set the binding shipment date below.
|
||||
</>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : null}
|
||||
<SectionCard icon={FileText} title="Route">
|
||||
{needsRouteSelect ? (
|
||||
<Select
|
||||
label="Contract route"
|
||||
placeholder="Select contract route"
|
||||
value={contractRouteId}
|
||||
onChange={setContractRouteId}
|
||||
data={routes.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"} → ${
|
||||
r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"
|
||||
}`,
|
||||
}))}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{selectedRoute
|
||||
? `${selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "Origin"} → ${
|
||||
selectedRoute.destinationYard?.label ??
|
||||
selectedRoute.destinationYard?.code ??
|
||||
"Destination"
|
||||
}`
|
||||
: "This contract's only route."}
|
||||
</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{isContainer ? (
|
||||
<SectionCard
|
||||
icon={ContainerIcon}
|
||||
title="Containers"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addContainerLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one container line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{containerLines.map((line, lineIdx) => (
|
||||
<Box
|
||||
key={lineIdx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {lineIdx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeContainerLine(lineIdx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<Select
|
||||
label="Container size"
|
||||
value={line.containerSize}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, {
|
||||
containerSize: v ?? line.containerSize,
|
||||
})
|
||||
}
|
||||
data={
|
||||
containerSizes.length > 0
|
||||
? containerSizes
|
||||
: ["20ft", "40ft"]
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { reeferQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Divider
|
||||
my="sm"
|
||||
label={`${line.units.length} container unit${
|
||||
line.units.length === 1 ? "" : "s"
|
||||
}`}
|
||||
labelPosition="left"
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Grid key={unitIdx} gap="xs" align="flex-end">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container no." : undefined}
|
||||
placeholder="MSKU1234567"
|
||||
value={unit.containerNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 3 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal no." : undefined}
|
||||
placeholder="Optional"
|
||||
value={unit.sealNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 5, sm: 3 }}>
|
||||
<NumberInput
|
||||
label={unitIdx === 0 ? "VGM (t)" : undefined}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={unit.vgmTons}
|
||||
onChange={(v) =>
|
||||
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 1, sm: 2 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={line.units.length === 1}
|
||||
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||
aria-label="Remove unit"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
))}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() => addUnit(lineIdx)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Add container unit
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={Package}
|
||||
title="Bulk cargo"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addBulkLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{bulkLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one bulk line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{bulkLines.map((line, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {idx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeBulkLine(idx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
{bulkCargoOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder="Select cargo type"
|
||||
value={line.cargoTypeId || null}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { cargoTypeId: v ?? "" })
|
||||
}
|
||||
data={bulkCargoOptions}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Cargo type id"
|
||||
placeholder="Optional"
|
||||
value={line.cargoTypeId}
|
||||
onChange={(e) =>
|
||||
patchBulk(idx, {
|
||||
cargoTypeId: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Weight (tons)"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={line.cargoWeightTons}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { cargoWeightTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Item count"
|
||||
min={0}
|
||||
value={line.itemCount}
|
||||
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={FileText} title="Schedule">
|
||||
{cargoQuery === null ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
>
|
||||
Enter the cargo details first — available shipment days depend on
|
||||
the wagons the cargo needs.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{bookingRequest?.scheduledDate ? (
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
Customer requested{" "}
|
||||
{new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(bookingRequest.scheduledDate))}{" "}
|
||||
— pick the binding shipment day below.
|
||||
</Text>
|
||||
) : null}
|
||||
<OperationDatePicker
|
||||
availableDays={availableDays ?? []}
|
||||
isLoading={daysLoading}
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={FileText} title="Notes">
|
||||
<Textarea
|
||||
placeholder="Internal GL notes (optional)"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => setPriceOpen(true)}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Price-confirm — GL reviews the estimate, then books on behalf of the
|
||||
customer. The server recomputes the authoritative total on submit. */}
|
||||
<Modal
|
||||
opened={priceOpen}
|
||||
onClose={() => {
|
||||
if (!mutations.createBooking.isPending) setPriceOpen(false);
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap={10}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Receipt size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={800} fz={16}>
|
||||
Confirm shipment price
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Booking on behalf of the customer for {contract.reference}.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{priceTotal ? (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius={16} p="lg">
|
||||
<Stack gap={10}>
|
||||
{priceTotal.lines.map((line, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" fw={500}>
|
||||
{line.label}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{line.quantity.toLocaleString()} ×{" "}
|
||||
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
|
||||
{formatRateUnit(line.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{line.amount.toLocaleString()} {priceTotal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{priceTotal.lines.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No priced lines — check the cargo details.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28}>
|
||||
{priceTotal.total.toLocaleString()}{" "}
|
||||
<Text span fz={16} fw={700} c="dimmed">
|
||||
{priceTotal.currency}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<X size={16} />}
|
||||
onClick={() => setPriceOpen(false)}
|
||||
disabled={mutations.createBooking.isPending}
|
||||
>
|
||||
Back to edit
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutations.createBooking.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Check, type LucideIcon } from "lucide-react";
|
||||
|
||||
export interface ActionShellProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** When true the action is already done — children are hidden, a done badge shows. */
|
||||
done?: boolean;
|
||||
doneLabel?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent container for one GL action card: icon, title, and either the
|
||||
* input controls (pending) or a completed badge (done). Keeps every GL action
|
||||
* visually uniform inside {@link GlActionsPanel}.
|
||||
*/
|
||||
export function ActionShell({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
done,
|
||||
doneLabel,
|
||||
children,
|
||||
}: ActionShellProps) {
|
||||
return (
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{done ? (
|
||||
typeof doneLabel === "string" || !doneLabel ? (
|
||||
<Badge
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
leftSection={<Check size={12} />}
|
||||
>
|
||||
{doneLabel ?? "Done"}
|
||||
</Badge>
|
||||
) : (
|
||||
doneLabel
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{!done ? children : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Receipt } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAdviseDuty } from "@/hooks/contracts/useContracts";
|
||||
import { ActionShell } from "./ActionShell";
|
||||
|
||||
export function AdviseDutyCard({
|
||||
bookingId,
|
||||
milestone,
|
||||
}: {
|
||||
bookingId: string;
|
||||
milestone: Freight.IClearanceMilestone;
|
||||
}) {
|
||||
const advise = useAdviseDuty(bookingId);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [serial, setSerial] = useState("");
|
||||
|
||||
const done = milestone.status === "COMPLETED";
|
||||
const meta = milestone.metadata;
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={Receipt}
|
||||
title="Duty & tax"
|
||||
subtitle="Advise the duty/tax amount and declaration serial."
|
||||
done={done}
|
||||
doneLabel={
|
||||
meta?.dutyAmount != null
|
||||
? `${meta.dutyAmount.toLocaleString()} ${meta.dutyCurrency ?? ""}`
|
||||
: "Advised"
|
||||
}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Declaration serial"
|
||||
placeholder="e.g. IM4-2026-00123"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Customer uploads the payment slip after being advised.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={advise.isPending}
|
||||
disabled={!amount || Number(amount) <= 0}
|
||||
onClick={() =>
|
||||
advise.mutate({
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
declarationSerial: serial.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Advise customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAssignRisk } from "@/hooks/contracts/useContracts";
|
||||
import { ActionShell } from "./ActionShell";
|
||||
|
||||
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
RED: "red",
|
||||
};
|
||||
|
||||
export function AssignRiskCard({
|
||||
bookingId,
|
||||
milestone,
|
||||
}: {
|
||||
bookingId: string;
|
||||
milestone: Freight.IClearanceMilestone;
|
||||
}) {
|
||||
const assign = useAssignRisk(bookingId);
|
||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
|
||||
|
||||
const assigned = milestone.status === "COMPLETED";
|
||||
const current = milestone.metadata?.riskLevel;
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={ShieldAlert}
|
||||
title="Customs risk"
|
||||
subtitle="Assign the customs examination risk level."
|
||||
done={assigned}
|
||||
doneLabel={
|
||||
current ? (
|
||||
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
||||
{current}
|
||||
</Badge>
|
||||
) : (
|
||||
"Assigned"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={level}
|
||||
onChange={(v) => setLevel(v as Freight.CustomsRiskLevel)}
|
||||
data={[
|
||||
{ label: "Green", value: "GREEN" },
|
||||
{ label: "Yellow", value: "YELLOW" },
|
||||
{ label: "Red", value: "RED" },
|
||||
]}
|
||||
/>
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Customer is notified of the assigned risk.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={assign.isPending}
|
||||
onClick={() => assign.mutate({ riskLevel: level })}
|
||||
>
|
||||
Assign risk
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Group, Select, Text } from "@mantine/core";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAssignStation } from "@/hooks/contracts/useContracts";
|
||||
import { ActionShell } from "./ActionShell";
|
||||
|
||||
/**
|
||||
* Routes the shipment to an origin station (GL US-02). Binding a staff user is
|
||||
* optional here — the station manager can assign one later.
|
||||
*/
|
||||
export function AssignStationCard({ bookingId }: { bookingId: string }) {
|
||||
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
|
||||
const assign = useAssignStation(bookingId);
|
||||
const [stationYardId, setStationYardId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={MapPin}
|
||||
title="Station routing"
|
||||
subtitle="Route this shipment to the handling station."
|
||||
>
|
||||
<Group align="flex-end" wrap="nowrap" gap="sm">
|
||||
<Select
|
||||
flex={1}
|
||||
label="Station"
|
||||
placeholder="Select station"
|
||||
searchable
|
||||
data={yards.map((y) => ({ value: y.id, label: y.label }))}
|
||||
value={stationYardId}
|
||||
onChange={setStationYardId}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={assign.isPending}
|
||||
disabled={!stationYardId}
|
||||
onClick={() =>
|
||||
stationYardId && assign.mutate({ stationYardId })
|
||||
}
|
||||
>
|
||||
Route
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
The shipment moves to the selected station's queue.
|
||||
</Text>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from "react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
import { AssignStationCard } from "./AssignStationCard";
|
||||
import { AssignRiskCard } from "./AssignRiskCard";
|
||||
import { AdviseDutyCard } from "./AdviseDutyCard";
|
||||
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
|
||||
import { IncidentReportCard } from "./IncidentReportCard";
|
||||
|
||||
export interface GlActionsPanelProps {
|
||||
bookingId: string;
|
||||
milestones: Freight.IClearanceMilestone[];
|
||||
}
|
||||
|
||||
/** Find a milestone by code (post-booking milestones live on the booking). */
|
||||
function findMilestone(
|
||||
milestones: Freight.IClearanceMilestone[],
|
||||
code: string,
|
||||
): Freight.IClearanceMilestone | undefined {
|
||||
return milestones.find((m) => m.milestoneCode === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Logistics action surface for a shipment. Each card is gated by whether
|
||||
* its milestone exists on this shipment (import vs export differ) and renders the
|
||||
* structured action (risk level, duty advice, document upload, incident report,
|
||||
* station routing) that the plain "Complete" button can't capture.
|
||||
*/
|
||||
export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
const riskMs = useMemo(
|
||||
() => findMilestone(milestones, "RISK_ASSIGNED"),
|
||||
[milestones],
|
||||
);
|
||||
const dutyMs = useMemo(
|
||||
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
|
||||
[milestones],
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
|
||||
<Stack gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
Structured GL operations for this shipment. Uploading a document
|
||||
advances its milestone automatically.
|
||||
</Text>
|
||||
|
||||
<AssignStationCard bookingId={bookingId} />
|
||||
|
||||
{dutyMs ? (
|
||||
<AdviseDutyCard bookingId={bookingId} milestone={dutyMs} />
|
||||
) : null}
|
||||
|
||||
<GlDocumentUploadCard bookingId={bookingId} />
|
||||
|
||||
{riskMs ? (
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
) : null}
|
||||
|
||||
<IncidentReportCard bookingId={bookingId} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { Eye, FileText, FileUp, Upload } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { ActionShell } from "./ActionShell";
|
||||
|
||||
/**
|
||||
* GL post-booking document slots. The fieldname (value) maps server-side to a
|
||||
* doc-triggered milestone in gl-operations.service.ts — uploading auto-advances
|
||||
* the matching milestone.
|
||||
*/
|
||||
const GL_DOC_SLOTS = [
|
||||
{ value: "delivery_order", label: "Delivery Order (DO)" },
|
||||
{ value: "release_order", label: "Release Order (RO)" },
|
||||
{ value: "t1_transport_document", label: "T1 Transport Document" },
|
||||
{ value: "import_release", label: "Import Release" },
|
||||
{ value: "full_in_interchange", label: "Full-in Interchange" },
|
||||
{ value: "final_declaration", label: "Final Declaration" },
|
||||
];
|
||||
|
||||
export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
|
||||
const upload = useUploadGlDocuments(bookingId);
|
||||
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const submit = () => {
|
||||
if (!slot || !file) return;
|
||||
upload.mutate({ [slot]: file });
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={FileUp}
|
||||
title="GL documents"
|
||||
subtitle="Upload DO, RO, T1, release, interchange — advances milestones."
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Document type"
|
||||
data={GL_DOC_SLOTS}
|
||||
value={slot}
|
||||
onChange={setSlot}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<Upload size={14} />}
|
||||
>
|
||||
{file ? file.name : "Choose file"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={upload.isPending}
|
||||
disabled={!file || !slot}
|
||||
onClick={submit}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
{file ? (
|
||||
<StagedFilePreview file={file} onPreview={view} />
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
PDF or image. The matching milestone completes on upload.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact preview chip for the GL file staged for upload: an image thumbnail
|
||||
* (or a glyph) and a Preview button that opens the file in the shared viewer via
|
||||
* a local object URL (minted once, revoked on unmount).
|
||||
*/
|
||||
function StagedFilePreview({
|
||||
file,
|
||||
onPreview,
|
||||
}: {
|
||||
file: File;
|
||||
onPreview: (f: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}) {
|
||||
const url = useMemo(() => URL.createObjectURL(file), [file]);
|
||||
useEffect(() => () => URL.revokeObjectURL(url), [url]);
|
||||
|
||||
const isImage =
|
||||
file.type.startsWith("image/") ||
|
||||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
|
||||
file.name.split(".").pop()?.toLowerCase() ?? "",
|
||||
);
|
||||
const canPreview = isViewable({ name: file.name, url, mimeType: file.type });
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={10}
|
||||
wrap="nowrap"
|
||||
p={8}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px dashed var(--mantine-color-edr-green-5)",
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<Box
|
||||
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
c="edr-green"
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<FileText size={17} />
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" fw={700} c="edr-green">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text size="xs" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{canPreview && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, ImagePlus } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
useBookingIncidents,
|
||||
useReportIncident,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { ActionShell } from "./ActionShell";
|
||||
|
||||
const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
|
||||
{ value: "SEAL_BROKEN", label: "Seal is broken" },
|
||||
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
||||
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
||||
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
||||
];
|
||||
|
||||
const LABEL: Record<Freight.IncidentType, string> = {
|
||||
SEAL_BROKEN: "Seal broken",
|
||||
CONTAINER_OPENED: "Container opened",
|
||||
CONTAINER_DAMAGED: "Container damaged",
|
||||
FLUID_LEAKING: "Fluid leaking",
|
||||
};
|
||||
|
||||
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
||||
const report = useReportIncident(bookingId);
|
||||
const { data: incidents } = useBookingIncidents(bookingId);
|
||||
const [type, setType] = useState<Freight.IncidentType>("SEAL_BROKEN");
|
||||
const [description, setDescription] = useState("");
|
||||
const [photos, setPhotos] = useState<File[]>([]);
|
||||
|
||||
const submit = () => {
|
||||
if (!description.trim()) return;
|
||||
report.mutate(
|
||||
{ incidentType: type, description: description.trim(), photos },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDescription("");
|
||||
setPhotos([]);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={AlertTriangle}
|
||||
title="Cargo exception"
|
||||
subtitle="Log a damage/anomaly with photo evidence (GL Djibouti)."
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{incidents && incidents.length > 0 ? (
|
||||
<Stack gap={4}>
|
||||
{incidents.map((inc) => (
|
||||
<Group key={inc.id} gap={8} wrap="nowrap">
|
||||
<Badge color="red" variant="light" radius="sm">
|
||||
{LABEL[inc.incidentType]}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{inc.description}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
label="Incident type"
|
||||
data={INCIDENT_OPTIONS}
|
||||
value={type}
|
||||
onChange={(v) => setType((v as Freight.IncidentType) ?? "SEAL_BROKEN")}
|
||||
size="sm"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Describe the anomaly…"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
size="sm"
|
||||
/>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<FileButton onChange={setPhotos} accept="image/jpeg,image/png" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ImagePlus size={14} />}
|
||||
>
|
||||
{photos.length > 0 ? `${photos.length} photo(s)` : "Add photos"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
loading={report.isPending}
|
||||
disabled={!description.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
Report incident
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface GlShipmentTotalLine {
|
||||
label: string;
|
||||
unitPrice: number;
|
||||
unit: Freight.ContractRateUnit | string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface GlShipmentTotal {
|
||||
currency: string;
|
||||
lines: GlShipmentTotalLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A normalized view of the form quantities, freight-shape agnostic. */
|
||||
export interface GlShipmentQuantities {
|
||||
isContainer: boolean;
|
||||
/** Container lines: size + total qty + hazardous/reefer qty. */
|
||||
containers: Array<{
|
||||
containerSize: string;
|
||||
quantity: number;
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
}>;
|
||||
/** Bulk: tons (or item count) + hazardous qty. */
|
||||
bulkQuantity: number;
|
||||
bulkHazardousQuantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the booking total client-side from the contract's frozen unit rates ×
|
||||
* the quantities GL enters. Mirrors the portal customer estimate
|
||||
* (new-shipment-form/total.ts) — the server recomputes the authoritative total
|
||||
* on submit. Shown in the price-confirm modal before GL books on behalf of the
|
||||
* customer.
|
||||
*/
|
||||
export function computeGlShipmentTotal(
|
||||
contract: Freight.IContract,
|
||||
q: GlShipmentQuantities,
|
||||
): GlShipmentTotal {
|
||||
const breakdown = contract.pricingBreakdown;
|
||||
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
|
||||
const items = breakdown?.lineItems ?? [];
|
||||
const lines: GlShipmentTotalLine[] = [];
|
||||
|
||||
const rateFor = (
|
||||
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
|
||||
) => items.find(predicate);
|
||||
|
||||
if (q.isContainer) {
|
||||
let hazardTotalQty = 0;
|
||||
let reeferTotalQty = 0;
|
||||
|
||||
for (const line of q.containers) {
|
||||
const qty = line.quantity;
|
||||
if (qty <= 0) continue;
|
||||
const rate =
|
||||
rateFor(
|
||||
(i) =>
|
||||
i.containerSize === line.containerSize &&
|
||||
i.unit === "per_container" &&
|
||||
!i.conditionalOn,
|
||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||
if (rate) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
hazardTotalQty += line.hazardousQuantity;
|
||||
reeferTotalQty += line.reeferQuantity;
|
||||
}
|
||||
|
||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
|
||||
if (hz) {
|
||||
lines.push({
|
||||
label: hz.label,
|
||||
unitPrice: hz.unitPrice,
|
||||
unit: hz.unit,
|
||||
quantity: hazardTotalQty,
|
||||
amount: hz.unitPrice * hazardTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (contract.isReefer && reeferTotalQty > 0) {
|
||||
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
|
||||
if (rf) {
|
||||
lines.push({
|
||||
label: rf.label,
|
||||
unitPrice: rf.unitPrice,
|
||||
unit: rf.unit,
|
||||
quantity: reeferTotalQty,
|
||||
amount: rf.unitPrice * reeferTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = q.bulkQuantity;
|
||||
const rate =
|
||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||
return { currency, lines, total };
|
||||
}
|
||||
|
||||
/** Human-readable label for a contract unit-rate's charge unit. */
|
||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||
const map: Record<string, string> = {
|
||||
per_container: "container",
|
||||
per_ton: "ton",
|
||||
per_item: "item",
|
||||
per_km: "km",
|
||||
flat: "flat",
|
||||
};
|
||||
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
|
||||
}
|
||||
@@ -47,6 +47,16 @@ const buildInitialValues = (
|
||||
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
|
||||
return;
|
||||
}
|
||||
// For selects, snap the record value onto a real option even if its casing
|
||||
// drifted (e.g. an API/seed value of "Available" vs the "AVAILABLE" option).
|
||||
// Otherwise the Select renders blank and a required field fails on submit.
|
||||
if (field.type === "select" && field.options?.length) {
|
||||
const match = field.options.find(
|
||||
(o) => String(o.value).toLowerCase() === String(raw).toLowerCase(),
|
||||
);
|
||||
values[field.name] = match ? match.value : raw;
|
||||
return;
|
||||
}
|
||||
values[field.name] = raw;
|
||||
});
|
||||
return values;
|
||||
@@ -66,12 +76,20 @@ const FleetFormDialog = ({
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
|
||||
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
|
||||
// dynamic select options finish loading). Re-seeding on those would wipe the
|
||||
// user's in-progress edits (e.g. a changed Current Yard / status) the moment
|
||||
// the yard or wagon-type options resolve.
|
||||
const recordId =
|
||||
initialRecord && "id" in initialRecord ? String(initialRecord.id) : null;
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, fields, emptyValues, initialRecord]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
@@ -119,6 +137,15 @@ const FleetFormDialog = ({
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
// Field types keyed by name, so the submit payload can coerce each value to the
|
||||
// type the API expects (number columns come back from the API as strings like
|
||||
// "24.00", which the DTO's @IsNumber rejects on an otherwise-unchanged save).
|
||||
const fieldTypeByName = useMemo(() => {
|
||||
const map: Record<string, FleetFormFieldDef["type"]> = {};
|
||||
fields.forEach((f) => (map[f.name] = f.type));
|
||||
return map;
|
||||
}, [fields]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
const payload = Object.fromEntries(
|
||||
@@ -126,6 +153,10 @@ const FleetFormDialog = ({
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "")
|
||||
return [key, undefined];
|
||||
if (fieldTypeByName[key] === "number") {
|
||||
const num = Number(value);
|
||||
return [key, Number.isNaN(num) ? undefined : num];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
|
||||
@@ -20,14 +20,11 @@ export const formatFleetCell = (
|
||||
format?: FleetColumnFormat,
|
||||
accessorKey?: string,
|
||||
): ReactNode => {
|
||||
console.log('formatFleetCell:', { value, format, accessorKey, type: typeof value });
|
||||
|
||||
if (format === "statusBadge") {
|
||||
const status = value == null || value === "" ? "—" : String(value);
|
||||
const getStatusColor = (st: string): string => {
|
||||
const s = st.toUpperCase();
|
||||
console.log('Status for color mapping:', s);
|
||||
if (s === "ACTIVE") return "green";
|
||||
if (s === "ACTIVE" || s === "AVAILABLE") return "green";
|
||||
if (s === "INACTIVE") return "gray";
|
||||
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
|
||||
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
|
||||
@@ -35,7 +32,6 @@ export const formatFleetCell = (
|
||||
return "gray";
|
||||
};
|
||||
const color = getStatusColor(status);
|
||||
console.log('Assigned color:', color, 'for status:', status);
|
||||
return (
|
||||
<Badge variant="light" color={color} size="sm" radius="md">
|
||||
{status}
|
||||
@@ -50,7 +46,5 @@ export const formatFleetCell = (
|
||||
}
|
||||
}
|
||||
|
||||
const result = formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||
console.log('formatRuleEngineCell result for', accessorKey, ':', result);
|
||||
return result;
|
||||
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Paper, Stack, Table, Text } from "@mantine/core";
|
||||
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import type { IOverviewRecentContract } from "@/types/overview";
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
return kind === "GENERAL" ? "General" : "One-time";
|
||||
}
|
||||
|
||||
export function OverviewRecentContractsTable({
|
||||
contracts,
|
||||
}: {
|
||||
contracts: IOverviewRecentContract[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Recent contracts</Text>
|
||||
{contracts.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent contracts
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Valid until</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{contracts.map((contract) => (
|
||||
<Table.Tr
|
||||
key={contract.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${contract.id}`)
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{contract.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{contract.customerLabel}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{kindLabel(contract.contractKind)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{contract.freightType === "CONTAINER" ? "Container" : "Bulk"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ContractStatusBadge status={contract.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{contract.validUntil
|
||||
? new Date(contract.validUntil).toLocaleDateString()
|
||||
: "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(contract.createdAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/
|
||||
import {
|
||||
useOverviewBillingTab,
|
||||
useOverviewBookingsTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
useOverviewStaffTab,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
@@ -32,6 +34,7 @@ interface OverviewTabContentProps {
|
||||
|
||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||
const operations = useOverviewOperationsTab(tab === "operations");
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
@@ -40,13 +43,15 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const query =
|
||||
tab === "bookings"
|
||||
? bookings
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
: tab === "contracts"
|
||||
? contracts
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
|
||||
const { isLoading, isError, refetch, isFetching } = query;
|
||||
|
||||
@@ -85,6 +90,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
{tab === "bookings" && bookings.data && (
|
||||
<OverviewBookingsTabPanel data={bookings.data} />
|
||||
)}
|
||||
{tab === "contracts" && contracts.data && (
|
||||
<OverviewContractsTabPanel data={contracts.data} />
|
||||
)}
|
||||
{tab === "billing" && billing.data && (
|
||||
<OverviewBillingTabPanel data={billing.data} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { CONTRACT_STATUS_META } from "@/features/contracts/contract-status.config";
|
||||
import type { IOverviewContractsTab } from "@/types/overview";
|
||||
import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewRecentContractsTable } from "../OverviewRecentContractsTable";
|
||||
|
||||
/** Human labels for the contract pipeline stages (see OVERVIEW_CONTRACT_PIPELINE on the API). */
|
||||
const PIPELINE_STAGE_LABELS: Record<string, string> = {
|
||||
draft: "Draft",
|
||||
intake: "Intake",
|
||||
in_approval: "In approval",
|
||||
signing: "Signing",
|
||||
clearance: "Clearance",
|
||||
active: "Active",
|
||||
closed: "Closed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
return kind === "GENERAL" ? "General" : kind === "ONE_TIME" ? "One-time" : kind;
|
||||
}
|
||||
|
||||
interface OverviewContractsTabPanelProps {
|
||||
data: IOverviewContractsTab;
|
||||
}
|
||||
|
||||
export function OverviewContractsTabPanel({
|
||||
data,
|
||||
}: OverviewContractsTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active contracts",
|
||||
value: data.kpis.totalActive,
|
||||
icon: FileSignature,
|
||||
accent: "emerald",
|
||||
hint: "Currently in workflow",
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: data.kpis.needsAction,
|
||||
icon: AlertCircle,
|
||||
accent: "amber",
|
||||
hint: "Awaiting your review",
|
||||
},
|
||||
{
|
||||
label: "In approval",
|
||||
value: data.kpis.inApproval,
|
||||
icon: UserCheck,
|
||||
accent: "sky",
|
||||
hint: "Pending sign-off",
|
||||
},
|
||||
{
|
||||
label: "In clearance",
|
||||
value: data.kpis.inClearance,
|
||||
icon: ShieldCheck,
|
||||
accent: "rose",
|
||||
hint: "Customs / documents",
|
||||
},
|
||||
{
|
||||
label: "Created today",
|
||||
value: data.kpis.createdToday,
|
||||
icon: FileSignature,
|
||||
hint: "New since midnight",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewBookingTrendChart data={data.contractTrend} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Pipeline by stage"
|
||||
data={data.contractsByPipeline.map((item) => ({
|
||||
label: PIPELINE_STAGE_LABELS[item.stage] ?? item.stage,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="By status"
|
||||
data={data.contractsByStatus.map((item) => ({
|
||||
name: CONTRACT_STATUS_META[item.status]?.title ?? item.status,
|
||||
value: item.count,
|
||||
}))}
|
||||
emptyMessage="No contracts yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="By kind"
|
||||
data={data.contractsByKind.map((item) => ({
|
||||
name: kindLabel(item.label),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewHorizontalBarChart
|
||||
title="By freight type"
|
||||
data={data.contractsByFreightType.map((item) => ({
|
||||
label: item.label === "CONTAINER" ? "Container" : "Bulk",
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<OverviewRecentContractsTable contracts={data.recentContracts} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import type { CompanyListFilter } from "@/types/customer";
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
@@ -45,6 +46,26 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
ROOT: ["contracts"] as const,
|
||||
list: (filter?: ContractListFilter) =>
|
||||
["contracts", "list", filter ?? {}] as const,
|
||||
listSummary: (filter?: ContractListFilter) =>
|
||||
["contracts", "list-summary", filter ?? {}] as const,
|
||||
byId: (id: string) => ["contracts", "detail", id] as const,
|
||||
clearance: (id: string) => ["contracts", "clearance", id] as const,
|
||||
clearanceQueue: (region?: string) =>
|
||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||
clearanceHistory: (region?: string) =>
|
||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||
bookingMilestones: (bookingId: string) =>
|
||||
["contracts", "booking-milestones", bookingId] as const,
|
||||
bookingIncidents: (bookingId: string) =>
|
||||
["contracts", "booking-incidents", bookingId] as const,
|
||||
},
|
||||
|
||||
BOOKING_ORDERS: {
|
||||
ROOT: ["booking-orders"] as const,
|
||||
byContract: (contractBookingId: string) =>
|
||||
@@ -108,6 +129,7 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["overview"] as const,
|
||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
||||
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
||||
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
|
||||
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: () => ["overview", "operations"] as const,
|
||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||
|
||||
@@ -88,6 +88,7 @@ export const URL_CONSTANTS = {
|
||||
OVERVIEW: {
|
||||
BASE: "/overview",
|
||||
BOOKINGS: "/overview/bookings",
|
||||
CONTRACTS: "/overview/contracts",
|
||||
BILLING: "/overview/billing",
|
||||
OPERATIONS: "/overview/operations",
|
||||
CUSTOMERS: "/overview/customers",
|
||||
@@ -124,6 +125,62 @@ export const URL_CONSTANTS = {
|
||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
BASE: "/contracts",
|
||||
LIST_SUMMARY: "/contracts/list-summary",
|
||||
BY_ID: (id: string) => `/contracts/${id}`,
|
||||
STAFF_ACCEPT: (id: string) => `/contracts/${id}/staff/accept`,
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/contracts/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||
`/contracts/${id}/clearance/ops-review`,
|
||||
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
||||
`/contracts/${id}/clearance/ops-finalize`,
|
||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
||||
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
|
||||
BOOKING_REQUEST_BY_ID: (reqId: string) =>
|
||||
`/contracts/booking-requests/${reqId}`,
|
||||
BOOKING_REQUESTS: (id: string) => `/contracts/${id}/booking-requests`,
|
||||
BOOKING_REQUEST_ACCEPT: (reqId: string) =>
|
||||
`/contracts/booking-requests/${reqId}/accept`,
|
||||
BOOKING_REQUEST_REJECT: (reqId: string) =>
|
||||
`/contracts/booking-requests/${reqId}/reject`,
|
||||
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/milestones`,
|
||||
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
|
||||
`/contracts/bookings/${bookingId}/milestones/${code}/complete`,
|
||||
// ── GL post-booking operational actions ──
|
||||
BOOKING_RISK: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/risk`,
|
||||
BOOKING_DUTY: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/duty`,
|
||||
BOOKING_STATION_ASSIGN: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/station-assign`,
|
||||
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/documents`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
OTP: {
|
||||
SEND: "/api/otp/send",
|
||||
VERIFY: "/api/otp/verify",
|
||||
@@ -149,6 +206,7 @@ export const URL_CONSTANTS = {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||
AVAILABLE_DAYS_FOR_CARGO: "/train-scheduling/available-days-for-cargo",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_BASE_API_URL ||
|
||||
import.meta.env.VITE_API_URL ||
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
* bytes through `GET /api/files/:id` (served from MinIO with backend
|
||||
* credentials) instead of a presigned MinIO URL — the latter is not reachable
|
||||
* from the browser and breaks on the minio-js port-443 signature quirk. Serves
|
||||
* inline for preview by default; pass `download` to force a save dialog.
|
||||
*/
|
||||
export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Coins,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
TrainTrack,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -38,7 +35,6 @@ export type BookingActionId =
|
||||
| "complete"
|
||||
| "operationAccept"
|
||||
| "operationRequestChanges"
|
||||
| "operationAdjustPrice"
|
||||
| "cancel";
|
||||
|
||||
export type BookingActionInputKind =
|
||||
@@ -198,20 +194,6 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
||||
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 = {
|
||||
@@ -282,7 +264,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
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,
|
||||
};
|
||||
@@ -388,47 +369,10 @@ export function getBookingActions(
|
||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||
break;
|
||||
case "PAID":
|
||||
if (
|
||||
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
||||
) {
|
||||
actions = [
|
||||
{
|
||||
id: "allocateBooking",
|
||||
label: "Allocate booking",
|
||||
shortLabel: "Allocate",
|
||||
description: "Assign to train, wagons, and finalize schedule",
|
||||
confirmTitle: "Allocate booking?",
|
||||
confirmDescription: "Opens the train allocation wizard.",
|
||||
variant: "default",
|
||||
icon: TrainTrack,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
id: "startTransit",
|
||||
label: "Start transit",
|
||||
shortLabel: "Transit",
|
||||
description: "Begin rail movement",
|
||||
confirmTitle: "Start transit?",
|
||||
confirmDescription: "The booking will move to in transit status.",
|
||||
variant: "default",
|
||||
icon: Truck,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
actions = [
|
||||
{
|
||||
id: "startTransit",
|
||||
label: "Start transit",
|
||||
shortLabel: "Transit",
|
||||
description: "Begin rail movement",
|
||||
confirmTitle: "Start transit?",
|
||||
confirmDescription: "The booking will move to in transit status.",
|
||||
variant: "default",
|
||||
icon: Truck,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
||||
// per-booking action menu. Start transit was removed entirely. No per-row
|
||||
// action remains in the PAID state.
|
||||
actions = [];
|
||||
break;
|
||||
case "IN_TRANSIT":
|
||||
actions = [
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ApprovalProgressSummary {
|
||||
label: string;
|
||||
detail: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function nextPending(
|
||||
steps: Freight.IContractApprovalStep[],
|
||||
): Freight.IContractApprovalStep | undefined {
|
||||
return steps.find((s) => s.status === "PENDING");
|
||||
}
|
||||
|
||||
/** Compact approval-chain summary for contract list rows (mirrors bookings). */
|
||||
export function formatContractApprovalProgress(
|
||||
status: string,
|
||||
steps?: Freight.IContractApprovalStep[] | null,
|
||||
): ApprovalProgressSummary {
|
||||
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (status === "SUBMITTED") {
|
||||
return {
|
||||
label: "Awaiting accept",
|
||||
detail: "Staff must accept intake",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status === "PENDING_APPROVAL" ||
|
||||
status === "APPROVED_PENDING_SIGNATURE"
|
||||
) {
|
||||
return {
|
||||
label: "No steps",
|
||||
detail: "Approval chain not started",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"CONTRACT_CLOSED",
|
||||
].includes(status)
|
||||
) {
|
||||
return {
|
||||
label: "Approved",
|
||||
detail: "Internal approval complete",
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
return { label: "—", detail: "", complete: false };
|
||||
}
|
||||
|
||||
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||
const total = sorted.length;
|
||||
const next = nextPending(sorted);
|
||||
|
||||
if (!next && approved === total) {
|
||||
return {
|
||||
label: `${approved}/${total} done`,
|
||||
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||
complete: approved === total,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { ContractStatus } from "@edr/types";
|
||||
|
||||
export interface StatusStyle {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Tailwind chip styling per contract status (mirrors booking-status.config). */
|
||||
export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
SUBMITTED: {
|
||||
label: "Submitted",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||
label: "Price Confirm",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
label: "Changes Requested",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
label: "Pending Approval",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
APPROVED: {
|
||||
label: "Approved",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
label: "Pending Signature",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
label: "Contract Ready",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
label: "Customer Signed",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
label: "Fully Executed",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
},
|
||||
CONTRACT_ACTIVE: {
|
||||
label: "Active",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Awaiting Documents",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
CLEARANCE_UNDER_REVIEW: {
|
||||
label: "Clearance Review",
|
||||
color: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
},
|
||||
CLEARANCE_READY_FOR_BOOKING: {
|
||||
label: "Ready for Booking",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: {
|
||||
label: "Shipment in Progress",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
label: "Closed",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
EXPIRED: {
|
||||
label: "Expired",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
REJECTED: {
|
||||
label: "Rejected",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelled",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
RENEWAL_DRAFT: {
|
||||
label: "Renewal Draft",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
RENEWAL_SUBMITTED: {
|
||||
label: "Renewal Submitted",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
RENEWAL_PENDING_APPROVAL: {
|
||||
label: "Renewal Approval",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
AMENDMENTS_PROPOSED: {
|
||||
label: "Amendments Proposed",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: "Archived",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
};
|
||||
|
||||
/** Mantine palette colour per contract status (mirrors BookingStatusBadge map). */
|
||||
export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
SUBMITTED: "yellow",
|
||||
PRICE_CHANGED_PENDING_CONFIRM: "yellow",
|
||||
CHANGES_REQUESTED: "orange",
|
||||
PENDING_APPROVAL: "yellow",
|
||||
APPROVED: "edr-green",
|
||||
APPROVED_PENDING_SIGNATURE: "cyan",
|
||||
CONTRACT_READY: "indigo",
|
||||
SIGNED_CUSTOMER: "cyan",
|
||||
FULLY_EXECUTED: "indigo",
|
||||
CONTRACT_ACTIVE: "edr-green",
|
||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
|
||||
CONTRACT_CLOSED: "gray",
|
||||
EXPIRED: "red",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
RENEWAL_DRAFT: "gray",
|
||||
RENEWAL_SUBMITTED: "yellow",
|
||||
RENEWAL_PENDING_APPROVAL: "yellow",
|
||||
AMENDMENTS_PROPOSED: "orange",
|
||||
ARCHIVED: "gray",
|
||||
};
|
||||
|
||||
export interface StatusMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
stage: number;
|
||||
}
|
||||
|
||||
export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
DRAFT: {
|
||||
title: "Draft",
|
||||
description: "Contract is being prepared by the customer.",
|
||||
color: "text-slate-500",
|
||||
stage: 0,
|
||||
},
|
||||
SUBMITTED: {
|
||||
title: "Submitted",
|
||||
description: "Awaiting staff review.",
|
||||
color: "text-amber-600",
|
||||
stage: 0,
|
||||
},
|
||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||
title: "Price Confirm",
|
||||
description: "Awaiting customer confirmation of revised unit rates.",
|
||||
color: "text-amber-600",
|
||||
stage: 0,
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
title: "Changes Requested",
|
||||
description: "Returned to customer for updates.",
|
||||
color: "text-orange-600",
|
||||
stage: 0,
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
title: "Pending Approval",
|
||||
description: "Moving through the internal approval chain.",
|
||||
color: "text-amber-600",
|
||||
stage: 1,
|
||||
},
|
||||
APPROVED: {
|
||||
title: "Approved",
|
||||
description: "Approved; contract document can be generated.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 1,
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
title: "Pending Signature",
|
||||
description: "Awaiting director or CEO signature steps.",
|
||||
color: "text-sky-600",
|
||||
stage: 1,
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
title: "Contract Ready",
|
||||
description: "Contract generated; awaiting customer signature.",
|
||||
color: "text-indigo-600",
|
||||
stage: 2,
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
title: "Customer Signed",
|
||||
description: "Awaiting contract execution.",
|
||||
color: "text-sky-600",
|
||||
stage: 2,
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Fully Executed",
|
||||
description: "One-time contract executed; transport-only path.",
|
||||
color: "text-indigo-600",
|
||||
stage: 3,
|
||||
},
|
||||
CONTRACT_ACTIVE: {
|
||||
title: "Active",
|
||||
description: "General contract active over its validity window.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
title: "Awaiting Documents",
|
||||
description: "Customer is uploading pre-booking clearance documents.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
CLEARANCE_UNDER_REVIEW: {
|
||||
title: "Clearance Review",
|
||||
description: "Global Logistics ET is reviewing clearance documents.",
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
CLEARANCE_READY_FOR_BOOKING: {
|
||||
title: "Ready for Booking",
|
||||
description: "Clearance complete — GL can create the shipment booking.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 4,
|
||||
},
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: {
|
||||
title: "Shipment in Progress",
|
||||
description: "A shipment booking is active under this contract.",
|
||||
color: "text-sky-600",
|
||||
stage: 4,
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
title: "Closed",
|
||||
description: "Contract fulfilled and closed.",
|
||||
color: "text-slate-500",
|
||||
stage: 5,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Expired",
|
||||
description: "Validity window elapsed.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Rejected",
|
||||
description: "Contract was rejected.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
CANCELLED: {
|
||||
title: "Cancelled",
|
||||
description: "Contract was cancelled.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
};
|
||||
|
||||
export const CONTRACT_LIST_TABS = [
|
||||
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
||||
{
|
||||
key: "intake",
|
||||
label: "Submitted",
|
||||
statuses: ["SUBMITTED", "PRICE_CHANGED_PENDING_CONFIRM", "CHANGES_REQUESTED"],
|
||||
},
|
||||
{
|
||||
key: "in_approval",
|
||||
label: "In approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
|
||||
},
|
||||
{
|
||||
key: "approved_contract",
|
||||
label: "Contract & signature",
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
key: "clearance",
|
||||
label: "Clearance",
|
||||
statuses: [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "Active",
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "closed",
|
||||
label: "Closed",
|
||||
statuses: ["CONTRACT_CLOSED", "EXPIRED", "REJECTED", "CANCELLED"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ContractStatusTabKey = (typeof CONTRACT_LIST_TABS)[number]["key"];
|
||||
|
||||
export const CONTRACT_WORKFLOW_STAGES = [
|
||||
{
|
||||
label: "Submission",
|
||||
statuses: [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
|
||||
},
|
||||
{
|
||||
label: "Signature",
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
label: "Clearance",
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipment",
|
||||
statuses: ["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"],
|
||||
},
|
||||
{ label: "Done", statuses: ["CONTRACT_CLOSED"] },
|
||||
] as const;
|
||||
|
||||
export function getContractStatusMeta(status: ContractStatus | string): StatusMeta {
|
||||
return (
|
||||
CONTRACT_STATUS_META[status] ?? {
|
||||
title: status,
|
||||
description: "",
|
||||
color: "text-muted-foreground",
|
||||
stage: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getContractWorkflowStageIndex(
|
||||
status: ContractStatus | string,
|
||||
): number {
|
||||
const meta = getContractStatusMeta(status);
|
||||
if (meta.stage < 0) return -1;
|
||||
return meta.stage;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ContractListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
approvalSteps?: Freight.IContractApprovalStep[];
|
||||
customerLabel: string;
|
||||
status: string;
|
||||
contractKind: Freight.ContractKind;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
validityDays?: number | null;
|
||||
isRenewal: boolean;
|
||||
createdAt: string;
|
||||
customsClearingEnabled: boolean;
|
||||
contractGeneratedAt?: string | null;
|
||||
}
|
||||
|
||||
/** The single most relevant next action for a contract row, for the table CTA. */
|
||||
export type ContractRowAction = {
|
||||
label: string;
|
||||
to: (id: string) => string;
|
||||
variant: "filled" | "light" | "default";
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a contract status to its one primary staff action. Returns null when the
|
||||
* only action is "open the detail page" (the row click already does that).
|
||||
*/
|
||||
export function getStaffRowAction(
|
||||
row: Pick<ContractListRow, "status" | "customsClearingEnabled" | "contractGeneratedAt">,
|
||||
): ContractRowAction | null {
|
||||
const detail = (id: string) => `/dashboard/contract-requests/${id}`;
|
||||
const view = (id: string) => `/dashboard/contract-requests/${id}/view`;
|
||||
|
||||
switch (row.status) {
|
||||
case "SUBMITTED":
|
||||
return { label: "Review", to: detail, variant: "filled" };
|
||||
case "PENDING_APPROVAL":
|
||||
return { label: "Approve", to: detail, variant: "filled" };
|
||||
case "CONTRACT_READY":
|
||||
case "SIGNED_CUSTOMER":
|
||||
return row.contractGeneratedAt
|
||||
? { label: "View & sign", to: view, variant: "filled" }
|
||||
: { label: "Open", to: detail, variant: "light" };
|
||||
case "CLEARANCE_UNDER_REVIEW":
|
||||
case "AWAITING_CLEARANCE_DOCUMENTS":
|
||||
return { label: "Review clearance", to: detail, variant: "filled" };
|
||||
case "CLEARANCE_READY_FOR_BOOKING":
|
||||
return row.customsClearingEnabled
|
||||
? { label: "Create booking", to: detail, variant: "filled" }
|
||||
: { label: "Open", to: detail, variant: "light" };
|
||||
default:
|
||||
return { label: "Open", to: detail, variant: "default" };
|
||||
}
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
export function toContractListRow(contract: Freight.IContract): ContractListRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
approvalSteps: contract.approvalSteps,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
status: contract.status,
|
||||
contractKind: contract.contractKind,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
validFrom: contract.contractValidFrom,
|
||||
validUntil: contract.contractValidUntil,
|
||||
validityDays: contract.contractValidityDays,
|
||||
isRenewal: Boolean(contract.renewalOfId),
|
||||
createdAt: contract.createdAt,
|
||||
customsClearingEnabled: Boolean(contract.customsClearingEnabled),
|
||||
contractGeneratedAt: contract.contractGeneratedAt,
|
||||
};
|
||||
}
|
||||
@@ -74,9 +74,8 @@ export function useBookingMutations(bookingId: string) {
|
||||
|
||||
const reviewOperation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||
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"),
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import {
|
||||
contractsService,
|
||||
type ContractListFilter,
|
||||
type SignContractPayload,
|
||||
} from "@/services/contracts.service";
|
||||
|
||||
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id) }),
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }),
|
||||
]).then(() => undefined);
|
||||
}
|
||||
|
||||
export function useContractList(filter?: ContractListFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.list(filter),
|
||||
queryFn: () => contractsService.list(filter),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractListSummary(
|
||||
filter?: ContractListFilter,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.listSummary(filter),
|
||||
queryFn: () => contractsService.getListSummary(filter),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractDetail(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.byId(id ?? ""),
|
||||
queryFn: () => contractsService.getById(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("GL"),
|
||||
queryFn: () => contractsService.getClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
|
||||
queryFn: () => contractsService.getOpsClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractClearanceHistory(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("GL"),
|
||||
queryFn: () => contractsService.getClearanceHistory(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsClearanceHistory(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("OPS"),
|
||||
queryFn: () => contractsService.getOpsClearanceHistory(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractMilestones(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
|
||||
queryFn: () => contractsService.listMilestonesForContract(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractCapacity(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.capacity(id ?? ""),
|
||||
queryFn: () => contractsService.getCapacity(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingMilestones(bookingId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),
|
||||
queryFn: () => contractsService.listMilestonesForBooking(bookingId!),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractMutations(contractId: string) {
|
||||
const qc = useQueryClient();
|
||||
const onSuccess = (data: { id: string }, message: string) => {
|
||||
toast.success(message);
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
};
|
||||
|
||||
const staffAccept = useMutation({
|
||||
mutationFn: (validityDays: number) =>
|
||||
contractsService.staffAccept(contractId, validityDays),
|
||||
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
|
||||
onError: () => toast.error("Failed to accept contract"),
|
||||
});
|
||||
|
||||
const requestChanges = useMutation({
|
||||
mutationFn: (note: string) =>
|
||||
contractsService.requestChanges(contractId, note),
|
||||
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
|
||||
onError: () => toast.error("Failed to request changes"),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract rejected"),
|
||||
onError: () => toast.error("Failed to reject contract"),
|
||||
});
|
||||
|
||||
// Statuses that mean every approval step is done and the contract is ready to
|
||||
// be generated. Once the final approval lands we generate the PDF
|
||||
// automatically — staff no longer click a separate "Generate" button.
|
||||
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||
onSuccess: async (data) => {
|
||||
// If this was the LAST approval, auto-generate the contract so it goes
|
||||
// straight to CONTRACT_READY without a manual step.
|
||||
const alreadyGenerated = Boolean(
|
||||
(data as Freight.IContract).contractGeneratedAt,
|
||||
);
|
||||
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
|
||||
toast.success("Final approval complete — generating contract…");
|
||||
try {
|
||||
const generated = await contractsService.generateContract(data.id);
|
||||
onSuccess(generated, "Contract generated and ready to sign");
|
||||
return;
|
||||
} catch {
|
||||
toast.error("Approved, but contract generation failed. Retry below.");
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSuccess(data, "Approval step completed");
|
||||
},
|
||||
onError: () => toast.error("Failed to approve step"),
|
||||
});
|
||||
|
||||
// Manual fallback generate — used only if auto-generation failed.
|
||||
const generateContract = useMutation({
|
||||
mutationFn: () => contractsService.generateContract(contractId),
|
||||
onSuccess: (data) => onSuccess(data, "Contract generated"),
|
||||
onError: () => toast.error("Failed to generate contract"),
|
||||
});
|
||||
|
||||
const signContract = useMutation({
|
||||
mutationFn: (payload: SignContractPayload) =>
|
||||
contractsService.signContract(contractId, payload),
|
||||
onSuccess: (data) => onSuccess(data, "Contract signed"),
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
});
|
||||
|
||||
const createBooking = useMutation({
|
||||
mutationFn: (payload: Freight.CreateBookingUnderContractDto) =>
|
||||
contractsService.createBookingUnderContract(contractId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Booking created under contract");
|
||||
void invalidateContractDetail(qc, contractId);
|
||||
},
|
||||
onError: () => toast.error("Failed to create booking"),
|
||||
});
|
||||
|
||||
const isPending =
|
||||
staffAccept.isPending ||
|
||||
requestChanges.isPending ||
|
||||
reject.isPending ||
|
||||
approveStep.isPending ||
|
||||
generateContract.isPending ||
|
||||
signContract.isPending ||
|
||||
createBooking.isPending;
|
||||
|
||||
return {
|
||||
staffAccept,
|
||||
requestChanges,
|
||||
reject,
|
||||
approveStep,
|
||||
generateContract,
|
||||
signContract,
|
||||
createBooking,
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-booking clearance mutations keyed on a contract. Pass `selfClear = true`
|
||||
* for Path A (non-customs) contracts so review/finalize hit the Operations
|
||||
* endpoints instead of the GL ET ones. Path A has no GL output upload step.
|
||||
*/
|
||||
export function useContractClearanceMutations(
|
||||
contractId: string,
|
||||
selfClear = false,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", "clearance-queue"],
|
||||
});
|
||||
void invalidateContractDetail(qc, contractId);
|
||||
};
|
||||
|
||||
const reviewDocument = useMutation({
|
||||
mutationFn: (p: {
|
||||
fileKey: string;
|
||||
status: "APPROVED" | "QUERIED";
|
||||
note?: string;
|
||||
}) =>
|
||||
selfClear
|
||||
? contractsService.opsReviewClearanceDocument(contractId, p)
|
||||
: contractsService.reviewClearanceDocument(contractId, p),
|
||||
onSuccess: (_d, p) => {
|
||||
toast.success(
|
||||
p.status === "APPROVED"
|
||||
? "Document approved"
|
||||
: "Query sent to customer",
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
});
|
||||
|
||||
// Approve every still-pending customer document in one click. There is no
|
||||
// server-side bulk endpoint, so fan out the single-document review calls and
|
||||
// refresh once after they all settle.
|
||||
const approveAll = useMutation({
|
||||
mutationFn: async (fileKeys: string[]) => {
|
||||
const review = selfClear
|
||||
? contractsService.opsReviewClearanceDocument
|
||||
: contractsService.reviewClearanceDocument;
|
||||
await Promise.all(
|
||||
fileKeys.map((fileKey) =>
|
||||
review(contractId, { fileKey, status: "APPROVED" }),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (_d, fileKeys) => {
|
||||
toast.success(
|
||||
`${fileKeys.length} document${fileKeys.length === 1 ? "" : "s"} approved`,
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not approve all documents"),
|
||||
});
|
||||
|
||||
const uploadOutputDocuments = useMutation({
|
||||
mutationFn: (files: Record<string, File | null>) =>
|
||||
contractsService.uploadClearanceOutput(contractId, files),
|
||||
onSuccess: () => {
|
||||
toast.success("Output documents uploaded");
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Upload failed"),
|
||||
});
|
||||
|
||||
const finalizeClearance = useMutation({
|
||||
mutationFn: () =>
|
||||
selfClear
|
||||
? contractsService.opsFinalizeClearance(contractId)
|
||||
: contractsService.finalizeClearance(contractId),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
selfClear
|
||||
? "Clearance approved — customer can now book"
|
||||
: "Clearance finalized — ready for booking",
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Could not finalize clearance",
|
||||
),
|
||||
});
|
||||
|
||||
return { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance };
|
||||
}
|
||||
|
||||
/** Complete a post-booking GL milestone. */
|
||||
export function useCompleteMilestone(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ code, note }: { code: string; note?: string }) =>
|
||||
contractsService.completeMilestone(bookingId, code, note),
|
||||
onSuccess: () => {
|
||||
toast.success("Milestone completed");
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to complete milestone"),
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateMilestones(qc: QueryClient, bookingId: string) {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Assign a customs risk level (completes RISK_ASSIGNED). */
|
||||
export function useAssignRisk(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
riskLevel: Freight.CustomsRiskLevel;
|
||||
note?: string;
|
||||
}) => contractsService.assignRisk(bookingId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Customs risk assigned");
|
||||
invalidateMilestones(qc, bookingId);
|
||||
},
|
||||
onError: () => toast.error("Failed to assign risk"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Advise duty & tax (completes DUTY_TAXES_ADVISED). */
|
||||
export function useAdviseDuty(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string;
|
||||
note?: string;
|
||||
}) => contractsService.adviseDuty(bookingId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Duty & tax advised to customer");
|
||||
invalidateMilestones(qc, bookingId);
|
||||
},
|
||||
onError: () => toast.error("Failed to advise duty & tax"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Route the shipment to a station + bind GL staff. */
|
||||
export function useAssignStation(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { stationYardId: string; staffId?: string }) =>
|
||||
contractsService.assignStation(bookingId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Shipment routed to station");
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to assign station"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload GL post-booking documents (DO/RO/T1/…); auto-completes milestones. */
|
||||
export function useUploadGlDocuments(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (files: Record<string, File | null>) =>
|
||||
contractsService.uploadGlDocuments(bookingId, files),
|
||||
onSuccess: (res) => {
|
||||
const n = res.completedMilestones.length;
|
||||
toast.success(
|
||||
n > 0
|
||||
? `Uploaded — ${n} milestone${n === 1 ? "" : "s"} advanced`
|
||||
: "Documents uploaded",
|
||||
);
|
||||
invalidateMilestones(qc, bookingId);
|
||||
},
|
||||
onError: () => toast.error("Failed to upload documents"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Incidents for a shipment (damage / exceptions). */
|
||||
export function useBookingIncidents(bookingId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId ?? ""),
|
||||
queryFn: () => contractsService.listIncidents(bookingId!),
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Report a cargo exception with photos. */
|
||||
export function useReportIncident(bookingId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: {
|
||||
incidentType: Freight.IncidentType;
|
||||
description: string;
|
||||
photos: File[];
|
||||
}) => contractsService.reportIncident(bookingId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Incident reported");
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to report incident"),
|
||||
});
|
||||
}
|
||||
24
apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
Normal file
24
apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
||||
* from any file row to open the document inline (pdf / image / video / office /
|
||||
* text); render `viewer` once near the page root.
|
||||
*
|
||||
* const { view, viewer } = useFileViewer();
|
||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
||||
* {viewer}
|
||||
*/
|
||||
export function useFileViewer() {
|
||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
||||
);
|
||||
|
||||
return { view, close, viewer };
|
||||
}
|
||||
@@ -19,6 +19,14 @@ export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewContractsTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.contractsTab(range),
|
||||
queryFn: () => overviewService.getContractsTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),
|
||||
|
||||
@@ -20,6 +20,21 @@ export const FREIGHT_PERMS = {
|
||||
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
||||
reject: "edr_freight_app:contracts:reject",
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: "edr_freight_app:contracts:sign_staff",
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
@@ -83,6 +98,31 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.view);
|
||||
}
|
||||
|
||||
/** Can review the GL Ethiopia pre-booking contract clearance queue (Path B). */
|
||||
export function canReviewContractClearance(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
}
|
||||
|
||||
/** GL Ethiopia: can create a booking under a cleared contract (Path B). */
|
||||
export function canCreateContractBooking(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
}
|
||||
|
||||
/** Operations: can review the Path A self-clearance queue (non-customs). */
|
||||
export function canReviewSelfClearance(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview);
|
||||
}
|
||||
|
||||
/** Can see/manage the customs document-clearance queue (Global Logistics). */
|
||||
export function canViewClearance(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
|
||||
|
||||
@@ -53,7 +53,7 @@ const BookingDetailPage = () => {
|
||||
id: "1",
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 11.25,
|
||||
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
|
||||
containerType: { label: "20FT Standard", sizeFt: 20 },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
@@ -288,6 +289,16 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
</Button>
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
@@ -46,12 +48,15 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
CLEARANCE_REVIEW_STATUS,
|
||||
CLEARANCE_TABS,
|
||||
type ClearanceTabKey,
|
||||
} from "@/features/clearance/clearance-tabs.config";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type PageTab = "queue" | "history";
|
||||
|
||||
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -62,6 +67,7 @@ interface ClearanceRow {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
scheduledDate: string;
|
||||
updatedAt: string;
|
||||
hasCustoms: boolean;
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
|
||||
originLabel: labelFromRef(booking.originYard),
|
||||
destinationLabel: labelFromRef(booking.destinationYard),
|
||||
scheduledDate: booking.scheduledDate,
|
||||
updatedAt: booking.updatedAt ?? "",
|
||||
hasCustoms: Boolean(
|
||||
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
|
||||
),
|
||||
@@ -102,12 +109,6 @@ function formatDate(iso?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
|
||||
* for export — on a light background, matching the "awaiting review" badge
|
||||
* styling. Keeps the cards within the white / light-gray / green palette and
|
||||
* drops the text label in favour of a tooltip.
|
||||
*/
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
@@ -129,33 +130,45 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
|
||||
export default function DocumentClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const isHistory = pageTab === "history";
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["clearance", "list"],
|
||||
queryKey: ["clearance", "list", isHistory],
|
||||
queryFn: () =>
|
||||
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
|
||||
bookingsService.list({
|
||||
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
||||
pageSize: 200,
|
||||
}),
|
||||
});
|
||||
|
||||
// GL clears customs bookings only; non-customs clearance is reviewed by
|
||||
// Marketing on the booking detail. Scope the queue defensively so a staff or
|
||||
// marketing user opening this page still sees the customs queue.
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
|
||||
[data?.items],
|
||||
);
|
||||
const allRows = useMemo(() => {
|
||||
// GL clearance queue: customs bookings only
|
||||
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
|
||||
|
||||
// Per-tab counts drive the badge on each tab.
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
if (isHistory) {
|
||||
return [...rows].sort((a, b) => {
|
||||
const ta = new Date(a.updatedAt || 0).getTime();
|
||||
const tb = new Date(b.updatedAt || 0).getTime();
|
||||
return tb - ta;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [data?.items, isHistory]);
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
} satisfies Record<ClearanceTabKey, number>;
|
||||
}, [allRows]);
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -185,6 +198,26 @@ export default function DocumentClearanceListPage() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const statusBadge = isHistory ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle size={13} />}
|
||||
>
|
||||
{tabCounts.all} cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{tabCounts.all} awaiting review
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -249,11 +282,16 @@ export default function DocumentClearanceListPage() {
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: () => (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
cell: () =>
|
||||
isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
@@ -265,7 +303,7 @@ export default function DocumentClearanceListPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[isHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -274,16 +312,7 @@ export default function DocumentClearanceListPage() {
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{tabCounts.all} awaiting review
|
||||
</Badge>
|
||||
}
|
||||
meta={statusBadge}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -298,13 +327,45 @@ export default function DocumentClearanceListPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={pageTab}
|
||||
onChange={(v) => {
|
||||
setPageTab(v as PageTab);
|
||||
setActiveTab("all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: "queue",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Inbox size={14} />
|
||||
<span>Queue</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "history",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Awaiting review",
|
||||
label: isHistory ? "Cleared" : "Awaiting review",
|
||||
value: tabCounts.all,
|
||||
icon: Inbox,
|
||||
icon: isHistory ? CheckCircle : Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
@@ -370,10 +431,7 @@ export default function DocumentClearanceListPage() {
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
@@ -430,9 +488,7 @@ export default function DocumentClearanceListPage() {
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
@@ -454,6 +510,7 @@ export default function DocumentClearanceListPage() {
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
isHistory={isHistory}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
@@ -467,10 +524,12 @@ export default function DocumentClearanceListPage() {
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
isHistory: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
@@ -495,7 +554,12 @@ function ClearanceCardGrid({
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
<ClearanceCard
|
||||
key={r.id}
|
||||
row={r}
|
||||
isHistory={isHistory}
|
||||
onOpen={() => onOpen(r.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
@@ -503,9 +567,11 @@ function ClearanceCardGrid({
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
isHistory: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -543,9 +609,15 @@ function ClearanceCard({
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
{isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Flag, ListChecks } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useCompleteMilestone,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingMilestonesPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const { data: milestones, isLoading } = useBookingMilestones(id);
|
||||
const complete = useCompleteMilestone(id ?? "");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = milestones ?? [];
|
||||
const total = list.length;
|
||||
const completed = list.filter((m) => m.status === "COMPLETED").length;
|
||||
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||
return { total, completed, pct };
|
||||
}, [milestones]);
|
||||
|
||||
const reference = booking?.reference ?? "Shipment";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`${reference} milestones`}
|
||||
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
|
||||
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: reference },
|
||||
{ label: "Milestones" },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ListChecks size={13} />}
|
||||
>
|
||||
{stats.completed}/{stats.total} done
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Flag} title="Clearance milestones">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={milestones ?? []}
|
||||
busy={complete.isPending}
|
||||
onComplete={(code, note) =>
|
||||
complete.mutate({ code, note })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{id ? (
|
||||
<GlActionsPanel
|
||||
bookingId={id}
|
||||
milestones={milestones ?? []}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard icon={ListChecks} title="Progress" accent="edr-green">
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
complete
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Box w="100%">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Milestones
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.completed}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
);
|
||||
const total = docs.length;
|
||||
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
|
||||
const queried = docs.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 };
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80} gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !clearance) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn’t load this contract’s clearance.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — customer books
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{ready ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Clearance finalized"
|
||||
>
|
||||
Customs clearance is complete. The customer can now create the
|
||||
shipment booking from the portal — no further action is needed here.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={ready}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceHero({
|
||||
contract,
|
||||
stats,
|
||||
}: {
|
||||
contract: ReturnType<typeof useContractDetail>["data"];
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
}) {
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const serviceName = contract?.serviceType?.serviceName ?? null;
|
||||
const customs =
|
||||
contract?.serviceType?.includesCustoms ??
|
||||
contract?.customsClearingEnabled ??
|
||||
false;
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const last = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
last?.destinationYard?.label ??
|
||||
last?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||
{contract?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{direction}
|
||||
</Badge>
|
||||
{customs ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{serviceName && (
|
||||
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
|
||||
{serviceName}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={8} mt={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Document review
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressStat({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700} fz={18} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="11px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
};
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="Clearance finalized — the customer creates the booking in the portal"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={12} />}
|
||||
>
|
||||
Clearance finalized
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
||||
* waiting for the customer to create the booking in the portal. A single list,
|
||||
* no queue/history/direction tabs.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(true);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
review: allRows.filter((r) => !r.ready).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{r.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
header: () => <span className={bookingTable.headerCell}>Service</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate maw={160}>
|
||||
{r.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={r.customs} />
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => <StatusBadge row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 150,
|
||||
cell: ({ row }) =>
|
||||
row.original.ready ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.original.id}/create-booking`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} need clearance
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Need clearance",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.review,
|
||||
icon: ShieldCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Ready — customer books",
|
||||
value: counts.ready,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, or route…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as ViewMode)}
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<TableIcon size={15} />
|
||||
<Box visibleFrom="sm">Table</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<LayoutGrid size={15} />
|
||||
<Box visibleFrom="sm">Cards</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box px="md" py="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts need customs clearance.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
pb="md"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="md"
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer", transition: "all 120ms ease" }}
|
||||
className="hover:border-edr-green-4 hover:shadow-md"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="edr-text" truncate>
|
||||
{row.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={11} className="shrink-0 opacity-70" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{row.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<StatusBadge row={row} />
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-edr-card-6)",
|
||||
border: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" justify="center">
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" mt="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<DirectionIcon direction={row.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{row.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt={8} wrap="nowrap" gap={8}>
|
||||
<Text size="xs" c="dimmed" truncate maw={150}>
|
||||
{row.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={row.customs} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Box as BoxIcon,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Flame,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
|
||||
// see which documents were approved, by whom, and when.
|
||||
const CLEARANCE_DONE_STATUSES = [
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
"EXPIRED",
|
||||
];
|
||||
|
||||
// Show the Clearance Review tab in either phase (active or done).
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
...CLEARANCE_ACTIVE_STATUSES,
|
||||
...CLEARANCE_DONE_STATUSES,
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: contract,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === "details") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="gray" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading contract…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileText size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Contract not found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to contract requests
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const statusMeta = getContractStatusMeta(contract.status);
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const showApprovalCard =
|
||||
contract.status === "PENDING_APPROVAL" ||
|
||||
contract.status === "APPROVED" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Contract requests", href: "/dashboard/contract-requests" },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Contract reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{contract.reference}
|
||||
</Title>
|
||||
<ContractStatusBadge
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Created ${formatDate(contract.createdAt)}`}
|
||||
/>
|
||||
{contract.contractValidUntil ? (
|
||||
<MetaItem
|
||||
icon={CalendarClock}
|
||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ContractWorkflowStepper
|
||||
status={contract.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No routes on this contract.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{routes.map((r) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.originYard?.label ??
|
||||
r.originYard?.code ??
|
||||
"Origin"}
|
||||
</Text>
|
||||
<ArrowRight
|
||||
size={15}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.km != null ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{r.km} km
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Package} title="Cargo scope">
|
||||
<Group gap="sm" mb="md">
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.tradeDirection}
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.freightType}
|
||||
</Badge>
|
||||
{contract.isHazardous ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={12} />}
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="cyan"
|
||||
radius="sm"
|
||||
leftSection={<Snowflake size={12} />}
|
||||
>
|
||||
Reefer
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{contract.pricingBreakdown?.lineItems?.length ? (
|
||||
<SectionCard icon={Receipt} title="Unit rates">
|
||||
<Stack gap="xs">
|
||||
{contract.pricingBreakdown.lineItems.map((li) => (
|
||||
<Group
|
||||
key={li.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{li.label}
|
||||
{li.containerSize ? ` · ${li.containerSize}` : ""}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
|
||||
{li.unit}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{contract.contractSummary ? (
|
||||
<SectionCard icon={FileText} title="Contract summary">
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{contract.contractSummary}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<ContractActionsToolbar
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
onReviewClearance={
|
||||
showClearanceTab ? () => setTab("clearance") : undefined
|
||||
}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
<ContractApprovalStepsCard
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={600} c="dark">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/components/contracts/ContractStatusTabs";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
getStaffRowAction,
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import {
|
||||
useContractList,
|
||||
useContractListSummary,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useContractList(filter);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useContractListSummary(filter);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toContractListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(c) =>
|
||||
c.reference.toLowerCase().includes(q) ||
|
||||
c.customerLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data?.items, query]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const metrics = summary?.metrics;
|
||||
const tabCounts = summary?.tabs;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
void refetchSummary();
|
||||
}, [refetch, refetchSummary]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ContractListRow) => {
|
||||
navigate(`/dashboard/contract-requests/${row.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{c.validUntil
|
||||
? `Until ${formatDate(c.validUntil)}`
|
||||
: c.validityDays
|
||||
? `${c.validityDays} days`
|
||||
: "—"}
|
||||
</span>
|
||||
{c.validFrom ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
From {formatDate(c.validFrom)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.contractKind === "GENERAL";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{isGeneral ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
||||
cell: ({ row }) => {
|
||||
const action = getStaffRowAction(row.original);
|
||||
if (!action) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant={action.variant === "filled" ? "filled" : action.variant}
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
// Don't let the row-click navigation fire as well.
|
||||
e.stopPropagation();
|
||||
navigate(action.to(row.original.id));
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract requests"
|
||||
subtitle="Review, approve, and execute freight contract requests."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "In queue",
|
||||
value: metrics?.inQueue ?? 0,
|
||||
icon: LayoutList,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: metrics?.needsAction ?? 0,
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: metrics?.urgent ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: "red",
|
||||
},
|
||||
{
|
||||
label: "Closed",
|
||||
value: tabCounts?.closed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ContractStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts match this view.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={handleRowClick}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
* contract here before signing — there is no sign action on the detail page or
|
||||
* the list table. Signing as STAFF is only possible once the contract has been
|
||||
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
|
||||
*/
|
||||
export default function ContractViewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Offer the staff member's saved signature first; they can draw a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
|
||||
queryFn: () => contractsService.getContractView(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.signContract(id!, {
|
||||
role: "STAFF",
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData ?? ""),
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Contract signed");
|
||||
setSignOpen(false);
|
||||
void refetch();
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
||||
},
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
});
|
||||
|
||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" mih="40vh" align="center">
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Text c="dimmed">Could not load contract.</Text>
|
||||
<Button variant="default" mt="md" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={{ base: "md", md: "xl" }}>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${data.contractId}`)
|
||||
}
|
||||
>
|
||||
Back to contract
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Printer size={16} />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={openSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Sign as staff"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
title="Contract document"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "80vh",
|
||||
border: "none",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={signOpen}
|
||||
onClose={() => setSignOpen(false)}
|
||||
title="Sign contract as staff"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.reference} — your signature is stored securely on the
|
||||
contract.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Full name"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
/>
|
||||
{usingSaved ? (
|
||||
<Stack gap="xs">
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
>
|
||||
<Image
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
fit="contain"
|
||||
h={140}
|
||||
/>
|
||||
</Paper>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={signMutation.isPending}
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
PackagePlus,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function lineRows(lines: Freight.RequestedShipmentLines) {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers.map(
|
||||
(c) =>
|
||||
`${c.quantity} × ${c.containerSize}` +
|
||||
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
|
||||
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
|
||||
);
|
||||
}
|
||||
if (lines.bulk) {
|
||||
const b = lines.bulk;
|
||||
const parts: string[] = [];
|
||||
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
|
||||
if (b.itemCount) parts.push(`${b.itemCount} items`);
|
||||
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
|
||||
return [parts.join(" · ") || "Bulk cargo"];
|
||||
}
|
||||
return ["—"];
|
||||
}
|
||||
|
||||
export default function ShipmentRequestDetailPage() {
|
||||
const { id: reqId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
const { data: request, isLoading } = useQuery({
|
||||
queryKey: ["shipment-request", reqId],
|
||||
queryFn: () => contractsService.getBookingRequest(reqId!),
|
||||
enabled: Boolean(reqId),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||||
navigate("/dashboard/shipment-requests");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn't load this shipment request.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = request.status === "PENDING";
|
||||
const contractRef = request.contract?.reference ?? request.contractId;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`Shipment request ${request.reference}`}
|
||||
subtitle={`On contract ${contractRef}`}
|
||||
backTo="/dashboard/shipment-requests"
|
||||
breadcrumbs={[
|
||||
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
|
||||
{ label: request.reference },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={
|
||||
request.status === "PENDING"
|
||||
? "edr-green"
|
||||
: request.status === "ACCEPTED"
|
||||
? "blue"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{request.status}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
isPending ? (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Accept & create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : request.status === "ACCEPTED" && request.createdBookingId ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/clearance/${request.createdBookingId}`)
|
||||
}
|
||||
>
|
||||
View booking clearance
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Preferred date (informational)
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtDate(request.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={6}>
|
||||
Quantities
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
>
|
||||
{l}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{request.notes ? (
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
Customer note
|
||||
</Text>
|
||||
<Text size="sm">{request.notes}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{request.reviewNote ? (
|
||||
<Alert color="red" variant="light" radius="md" mt="sm">
|
||||
Rejected: {request.reviewNote}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
centered
|
||||
radius="md"
|
||||
title="Reject shipment request"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Tell the customer why this request can't proceed…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setRejectOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
onClick={() => reject.mutate()}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const cellMeta = {
|
||||
headerClassName: ruleEngineTable.headerCell,
|
||||
cellClassName: ruleEngineTable.bodyCell,
|
||||
};
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
/** Summarize requested quantities for the list row. */
|
||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers
|
||||
.map((c) => `${c.quantity}× ${c.containerSize}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (lines.bulk) {
|
||||
const b = lines.bulk;
|
||||
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
|
||||
if (b.itemCount) return `${b.itemCount} items`;
|
||||
return "Bulk";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export default function ShipmentRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const rows = useMemo<RequestRow[]>(() => {
|
||||
const all = (data ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(r.requestedLines ?? {}),
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.contractReference.toLowerCase().includes(q) ||
|
||||
r.summary.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, query]);
|
||||
|
||||
const columns = useMemo<ColumnDef<RequestRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Request",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
header: "Contract",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="gray.7">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: "Requested",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{row.original.summary}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: "Preferred date",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 56,
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipment Requests"
|
||||
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageSearch size={13} />}
|
||||
>
|
||||
{rows.length} pending
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
radius="md"
|
||||
maw={360}
|
||||
placeholder="Search request, contract, cargo…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<Box
|
||||
py={56}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Inbox size={26} className="text-muted-foreground" />
|
||||
<Text c="dimmed" mt="sm">
|
||||
No pending shipment requests.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/shipment-requests/${row.id}`)
|
||||
}
|
||||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfile,
|
||||
@@ -286,7 +287,7 @@ export default function CustomerDetailPage() {
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={row.original.url ?? "#"}
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
UserCheck,
|
||||
@@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{
|
||||
value: OverviewTabKey;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
|
||||
kpiKey:
|
||||
| "bookings"
|
||||
| "contracts"
|
||||
| "billing"
|
||||
| "operations"
|
||||
| "customers"
|
||||
| "staff";
|
||||
metricKey: string;
|
||||
}> = [
|
||||
{
|
||||
@@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{
|
||||
kpiKey: "bookings",
|
||||
metricKey: "totalActive",
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
label: "Contracts",
|
||||
icon: FileSignature,
|
||||
kpiKey: "contracts",
|
||||
metricKey: "totalActive",
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
|
||||
@@ -246,7 +246,6 @@ const FleetResourcePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
|
||||
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
|
||||
return formatFleetCell(value, col.format, col.accessorKey);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -147,8 +147,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
],
|
||||
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
@@ -160,12 +160,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
],
|
||||
emptyValues: {
|
||||
code: "",
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 0,
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
|
||||
@@ -183,7 +183,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "container-types",
|
||||
label: "Container Types",
|
||||
category: "configuration",
|
||||
subtitle: "Configure container sizes and wagon capacity",
|
||||
subtitle: "Configure container sizes",
|
||||
searchPlaceholder: "Search container types...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
@@ -191,14 +191,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
||||
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
|
||||
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
if (!data) return { awaitingPayment: [], expired: [] };
|
||||
const all = [
|
||||
// Every booking on this schedule, flattened across windows + pending-contract,
|
||||
// de-duplicated (a booking only appears once). Feeds the management table.
|
||||
const allBookings = useMemo(() => {
|
||||
if (!data) return [] as BatchBoardBookingDetail[];
|
||||
const merged = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
const byId = new Map<string, BatchBoardBookingDetail>();
|
||||
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
|
||||
return [...byId.values()];
|
||||
}, [data]);
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
const all = allBookings;
|
||||
return {
|
||||
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
||||
expired: all.filter((b) => b.state === "EXPIRED"),
|
||||
};
|
||||
}, [data]);
|
||||
}, [allBookings]);
|
||||
|
||||
const bookingsReadOnly = useMemo(
|
||||
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
|
||||
[data?.status],
|
||||
);
|
||||
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Package size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Manage bookings</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Search and filter every booking on this train. Remove an
|
||||
allocated booking to free its wagons, or re-assign one that
|
||||
is not yet allocated — individually or in bulk.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<BookingsManager
|
||||
scheduleId={scheduleId ?? ""}
|
||||
bookings={allBookings}
|
||||
onChanged={() => void refetch()}
|
||||
readOnly={bookingsReadOnly}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
MoreVertical,
|
||||
PackagePlus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const cellMeta = {
|
||||
headerClassName: ruleEngineTable.headerCell,
|
||||
cellClassName: ruleEngineTable.bodyCell,
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green" },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
|
||||
READY: { label: "Ready for batch", color: "teal" },
|
||||
WAITING: { label: "Paid · waiting", color: "blue" },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const ALLOC_META: Record<
|
||||
BookingAllocationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
|
||||
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
|
||||
DEFERRED: { label: "Deferred", color: "orange" },
|
||||
FAILED: { label: "Allocation failed", color: "red" },
|
||||
};
|
||||
|
||||
const STATE_FILTERS = [
|
||||
{ value: "ALL", label: "All states" },
|
||||
...Object.entries(STATE_META).map(([value, m]) => ({
|
||||
value,
|
||||
label: m.label,
|
||||
})),
|
||||
];
|
||||
|
||||
const ALLOC_FILTERS = [
|
||||
{ value: "ALL", label: "All allocations" },
|
||||
...Object.entries(ALLOC_META).map(([value, m]) => ({
|
||||
value,
|
||||
label: m.label,
|
||||
})),
|
||||
];
|
||||
|
||||
export interface BookingsManagerProps {
|
||||
scheduleId: string;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
|
||||
onChanged: () => void;
|
||||
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable, filterable, bulk-manageable booking table for the batch board.
|
||||
* Staff can search by reference / customer, filter by batch state and wagon
|
||||
* allocation status, and remove or re-assign bookings individually or in bulk.
|
||||
* Wraps the shared DataTable; selection + actions are handled locally so the
|
||||
* surrounding accordion / tab layout stays untouched.
|
||||
*/
|
||||
export function BookingsManager({
|
||||
scheduleId,
|
||||
bookings,
|
||||
onChanged,
|
||||
readOnly = false,
|
||||
}: BookingsManagerProps) {
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [stateFilter, setStateFilter] = useState<string>("ALL");
|
||||
const [allocFilter, setAllocFilter] = useState<string>("ALL");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirm, setConfirm] = useState<
|
||||
| { kind: "remove"; ids: string[]; label: string }
|
||||
| { kind: "reassign"; ids: string[]; label: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const unassign = useMutation(
|
||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||
);
|
||||
const reassign = useMutation(
|
||||
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
|
||||
);
|
||||
const busy = unassign.isPending || reassign.isPending;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return bookings.filter((b) => {
|
||||
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
|
||||
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
|
||||
return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
b.reference.toLowerCase().includes(q) ||
|
||||
b.company.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [bookings, query, stateFilter, allocFilter]);
|
||||
|
||||
// Selection is bounded to whatever is currently visible (filtered) to avoid
|
||||
// acting on rows the user can't see.
|
||||
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
|
||||
const selectedVisible = useMemo(
|
||||
() => visibleIds.filter((id) => selected.has(id)),
|
||||
[visibleIds, selected],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
|
||||
const someVisibleSelected =
|
||||
selectedVisible.length > 0 && !allVisibleSelected;
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allVisibleSelected) {
|
||||
visibleIds.forEach((id) => next.delete(id));
|
||||
} else {
|
||||
visibleIds.forEach((id) => next.add(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const runRemove = async (ids: string[]) => {
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
// Sequential — each unassign mutates the schedule graph; parallel would race.
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
|
||||
ok += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Bookings removed",
|
||||
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
clearSelection();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const runReassign = async (ids: string[]) => {
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
|
||||
ok += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
toast({
|
||||
title: "Re-assignment run",
|
||||
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
clearSelection();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!confirm) return;
|
||||
const ids = confirm.ids;
|
||||
setConfirm(null);
|
||||
if (confirm.kind === "remove") await runRemove(ids);
|
||||
else await runReassign(ids);
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
|
||||
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
|
||||
|
||||
if (!readOnly) {
|
||||
cols.push({
|
||||
id: "select",
|
||||
meta: cellMeta,
|
||||
header: () => (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
aria-label="Select all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={someVisibleSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
aria-label={`Select ${row.original.reference}`}
|
||||
checked={selected.has(row.original.id)}
|
||||
onChange={() => toggleOne(row.original.id)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "reference",
|
||||
header: "Reference",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "customer",
|
||||
header: "Customer",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
background: "#FEF1D5",
|
||||
border: "1px solid #FBD171",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
|
||||
{initials(b.company)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" c="gray.7" truncate>
|
||||
{b.company}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "selectedForBatch",
|
||||
header: "Selected for batch",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
if (!b.selectedForBatchAt)
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||||
</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "capacity",
|
||||
header: "Capacity",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{b.wagons}w
|
||||
</Badge>
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{fmtTons(b.weightTons)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
header: "Batch state",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const m = STATE_META[row.original.state];
|
||||
return (
|
||||
<Badge variant="light" color={m.color} radius="sm">
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "allocation",
|
||||
header: "Wagon allocation",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const m = ALLOC_META[b.allocationStatus];
|
||||
const badge = (
|
||||
<Badge variant="light" color={m.color} radius="sm">
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
if (!b.allocationIssue) return badge;
|
||||
return (
|
||||
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{badge}
|
||||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!readOnly) {
|
||||
cols.push({
|
||||
id: "actions",
|
||||
header: "",
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const isAssigned = b.allocationStatus === "ASSIGNED";
|
||||
return (
|
||||
<Group justify="flex-end" gap={4} wrap="nowrap">
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
disabled={isAssigned || busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "reassign",
|
||||
ids: [b.id],
|
||||
label: b.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
Re-assign to wagons
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
disabled={!isAssigned || busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "remove",
|
||||
ids: [b.id],
|
||||
label: b.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove from train
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
readOnly,
|
||||
selected,
|
||||
allVisibleSelected,
|
||||
someVisibleSelected,
|
||||
visibleIds,
|
||||
busy,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* Toolbar: search + filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw={220}
|
||||
radius="md"
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={170}
|
||||
data={STATE_FILTERS}
|
||||
value={stateFilter}
|
||||
onChange={(v) => setStateFilter(v ?? "ALL")}
|
||||
aria-label="Filter by batch state"
|
||||
/>
|
||||
<Select
|
||||
radius="md"
|
||||
w={180}
|
||||
data={ALLOC_FILTERS}
|
||||
value={allocFilter}
|
||||
onChange={(v) => setAllocFilter(v ?? "ALL")}
|
||||
aria-label="Filter by allocation"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{filtered.length} of {bookings.length}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{!readOnly && selectedVisible.length > 0 ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
borderColor: "var(--mantine-color-edr-green-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge color="edr-green" radius="sm">
|
||||
{selectedVisible.length} selected
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
loading={reassign.isPending}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "reassign",
|
||||
ids: selectedVisible,
|
||||
label: `${selectedVisible.length} booking(s)`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Re-assign
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
loading={unassign.isPending}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
kind: "remove",
|
||||
ids: selectedVisible,
|
||||
label: `${selectedVisible.length} booking(s)`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
status="success"
|
||||
emptyMessage={
|
||||
bookings.length
|
||||
? "No bookings match the current search / filters."
|
||||
: "No bookings in this batch window."
|
||||
}
|
||||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(confirm)}
|
||||
onClose={() => setConfirm(null)}
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
confirm?.kind === "remove"
|
||||
? "Remove from train"
|
||||
: "Re-assign to wagons"
|
||||
}
|
||||
>
|
||||
<Text size="sm" mb="lg">
|
||||
{confirm?.kind === "remove"
|
||||
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
|
||||
: `Re-assign ${confirm?.label} to available wagons on this train?`}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={confirm?.kind === "remove" ? "red" : "edr-green"}
|
||||
loading={busy}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingsManager;
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
PreviewSummary,
|
||||
ScheduleWarningsAlert,
|
||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
@@ -146,26 +145,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
const containerUnits = previewResult?.containerUnits ?? [];
|
||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||
const hasContainerStep = useMemo(
|
||||
() =>
|
||||
shouldShowContainerPlacementStep({
|
||||
containerUnitCount: containerUnits.length,
|
||||
scheduleFreightType: freightType,
|
||||
bookingFreightTypes: [
|
||||
...(schedule?.bookings ?? []).map((b) => b.freightType),
|
||||
...(eligibleQuery.data?.items ?? [])
|
||||
.filter((item) => allSelectedIds.includes(item.id))
|
||||
.map((item) => item.freightType),
|
||||
],
|
||||
}),
|
||||
[
|
||||
allSelectedIds,
|
||||
containerUnits.length,
|
||||
eligibleQuery.data?.items,
|
||||
freightType,
|
||||
schedule?.bookings,
|
||||
],
|
||||
);
|
||||
// Container-number placement step removed — the customer enters container
|
||||
// numbers when booking, so scheduling skips straight from the wagon plan to
|
||||
// finalize. Steps: select bookings → review wagons → finalize.
|
||||
const hasContainerStep = false;
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
||||
|
||||
@@ -284,6 +284,32 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
availableDaysForCargo: endpoint<
|
||||
{
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
cargoTypeCode?: string;
|
||||
totalWeightTons?: number;
|
||||
containers?: { containerSize: string; quantity: number }[];
|
||||
},
|
||||
string[]
|
||||
>(
|
||||
"train-scheduling",
|
||||
"available-days-for-cargo",
|
||||
(input) => trainSchedulingService.getAvailableDaysForCargo(input),
|
||||
(input) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"available-days-for-cargo",
|
||||
input.originYardId ?? "",
|
||||
input.destinationYardId ?? "",
|
||||
input.freightType,
|
||||
input.cargoTypeCode ?? "",
|
||||
input.totalWeightTons ?? 0,
|
||||
JSON.stringify(input.containers ?? []),
|
||||
],
|
||||
),
|
||||
|
||||
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
|
||||
"train-scheduling",
|
||||
"track",
|
||||
@@ -1845,13 +1871,12 @@ export const api = {
|
||||
reviewOperation: endpoint<
|
||||
{
|
||||
id: string;
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||
note?: string;
|
||||
amount?: number;
|
||||
},
|
||||
BookingDetail
|
||||
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
|
||||
bookingsService.reviewOperation(id, decision, { note, amount }),
|
||||
>("bookings", "reviewOperation", ({ id, decision, note }) =>
|
||||
bookingsService.reviewOperation(id, decision, { note }),
|
||||
),
|
||||
|
||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||
|
||||
@@ -212,21 +212,14 @@ export const bookingsService = {
|
||||
/** Marketing/operations review of a drawdown order's operation request. */
|
||||
reviewOperation: (
|
||||
id: string,
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
|
||||
options: { note?: string; amount?: number } = {},
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES",
|
||||
options: { note?: string } = {},
|
||||
) =>
|
||||
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 }),
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
const C = URL_CONSTANTS.CONTRACTS;
|
||||
|
||||
export interface ContractListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs. */
|
||||
statuses?: string;
|
||||
/** Tab key for React Query cache (not sent to API). */
|
||||
tab?: string;
|
||||
companyId?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
contractKind?: string;
|
||||
paymentCurrency?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface PaginatedContracts {
|
||||
items: Freight.IContract[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryMetrics {
|
||||
inQueue: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummaryTabs {
|
||||
all: number;
|
||||
intake: number;
|
||||
in_approval: number;
|
||||
approved_contract: number;
|
||||
clearance: number;
|
||||
active: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
export interface ContractListSummary {
|
||||
metrics: ContractListSummaryMetrics;
|
||||
tabs: ContractListSummaryTabs;
|
||||
}
|
||||
|
||||
export interface ContractView {
|
||||
contractId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
templateKey: string;
|
||||
title: string;
|
||||
html: string;
|
||||
canSignCustomer: boolean;
|
||||
canSignStaff: boolean;
|
||||
hasContractDocument: boolean;
|
||||
signatures: Array<{
|
||||
role: string;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}>;
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
||||
const response = await client.post<T>(url, body ?? {});
|
||||
return unwrap(response.data);
|
||||
}
|
||||
|
||||
function buildListParams(filter?: ContractListFilter) {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (filter) {
|
||||
if (filter.statuses) params.statuses = filter.statuses;
|
||||
else if (filter.status) params.status = filter.status;
|
||||
if (filter.page != null) params.page = filter.page;
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.contractKind) params.contractKind = filter.contractKind;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export const contractsService = {
|
||||
getListSummary: async (
|
||||
filter?: ContractListFilter,
|
||||
): Promise<ContractListSummary> => {
|
||||
const response = await client.get<ContractListSummary>(C.LIST_SUMMARY, {
|
||||
params: buildListParams(filter),
|
||||
});
|
||||
return unwrap(response.data) as ContractListSummary;
|
||||
},
|
||||
|
||||
list: async (filter?: ContractListFilter): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.BASE, {
|
||||
params: buildListParams(filter),
|
||||
});
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
// ── Staff review ──
|
||||
staffAccept: (id: string, validityDays: number) =>
|
||||
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
|
||||
|
||||
requestChanges: (id: string, note: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
|
||||
|
||||
reject: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||
|
||||
approveStep: ({
|
||||
id,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
|
||||
requiredRole,
|
||||
}),
|
||||
|
||||
// ── Contract document ──
|
||||
generateContract: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
||||
|
||||
getContractView: async (id: string): Promise<ContractView> => {
|
||||
const response = await client.get<ContractView>(C.CONTRACT_VIEW(id));
|
||||
return unwrap(response.data) as ContractView;
|
||||
},
|
||||
|
||||
signContract: (id: string, payload: SignContractPayload) =>
|
||||
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
||||
|
||||
// ── Pre-booking clearance (Path B — GL ET) ──
|
||||
getClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id));
|
||||
return unwrap(response.data) as Freight.ContractClearanceView;
|
||||
},
|
||||
|
||||
reviewClearanceDocument: (
|
||||
id: string,
|
||||
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||
) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
||||
|
||||
uploadClearanceOutput: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.CLEARANCE_OUTPUT_DOCUMENTS(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
finalizeClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_QUEUE,
|
||||
);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getClearanceHistory: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
|
||||
const data = unwrap(response.data);
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
|
||||
const data = unwrap(response.data);
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
opsReviewClearanceDocument: (
|
||||
id: string,
|
||||
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||
) => postContract<Freight.IContract>(C.OPS_CLEARANCE_REVIEW(id), payload),
|
||||
|
||||
opsFinalizeClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
|
||||
|
||||
// ── Booking under contract (GL ET — Path B) ──
|
||||
createBookingUnderContract: (
|
||||
id: string,
|
||||
payload: Freight.CreateBookingUnderContractDto,
|
||||
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
||||
|
||||
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||
const response = await client.get(C.CAPACITY(id));
|
||||
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
|
||||
},
|
||||
|
||||
// ── Shipment requests (GENERAL + customs) ──
|
||||
/** GL queue of pending shipment requests across contracts. */
|
||||
getBookingRequestQueue: async (): Promise<Freight.IBookingRequest[]> => {
|
||||
const response = await client.get(C.BOOKING_REQUEST_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
|
||||
},
|
||||
|
||||
listBookingRequests: async (
|
||||
id: string,
|
||||
): Promise<Freight.IBookingRequest[]> => {
|
||||
const response = await client.get(C.BOOKING_REQUESTS(id));
|
||||
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
|
||||
},
|
||||
|
||||
getBookingRequest: async (
|
||||
reqId: string,
|
||||
): Promise<Freight.IBookingRequest> => {
|
||||
const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId));
|
||||
return unwrap(response.data) as Freight.IBookingRequest;
|
||||
},
|
||||
|
||||
acceptBookingRequest: (reqId: string, bookingId: string) =>
|
||||
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_ACCEPT(reqId), {
|
||||
bookingId,
|
||||
}),
|
||||
|
||||
rejectBookingRequest: (reqId: string, note?: string) =>
|
||||
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_REJECT(reqId), {
|
||||
note,
|
||||
}),
|
||||
|
||||
// ── Clearance milestones ──
|
||||
listMilestonesForContract: async (
|
||||
id: string,
|
||||
): Promise<Freight.IClearanceMilestone[]> => {
|
||||
const response = await client.get(C.MILESTONES(id));
|
||||
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
||||
},
|
||||
|
||||
listMilestonesForBooking: async (
|
||||
bookingId: string,
|
||||
): Promise<Freight.IClearanceMilestone[]> => {
|
||||
const response = await client.get(C.BOOKING_MILESTONES(bookingId));
|
||||
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
|
||||
},
|
||||
|
||||
completeMilestone: (bookingId: string, code: string, note?: string) =>
|
||||
postContract<Freight.IClearanceMilestone>(
|
||||
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
|
||||
{ note },
|
||||
),
|
||||
|
||||
// ── GL post-booking operational actions ──
|
||||
assignRisk: (
|
||||
bookingId: string,
|
||||
payload: { riskLevel: Freight.CustomsRiskLevel; note?: string },
|
||||
) =>
|
||||
postContract<Freight.IClearanceMilestone>(
|
||||
C.BOOKING_RISK(bookingId),
|
||||
payload,
|
||||
),
|
||||
|
||||
adviseDuty: (
|
||||
bookingId: string,
|
||||
payload: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string;
|
||||
note?: string;
|
||||
},
|
||||
) =>
|
||||
postContract<Freight.IClearanceMilestone>(
|
||||
C.BOOKING_DUTY(bookingId),
|
||||
payload,
|
||||
),
|
||||
|
||||
assignStation: (
|
||||
bookingId: string,
|
||||
payload: { stationYardId: string; staffId?: string },
|
||||
) => postContract(C.BOOKING_STATION_ASSIGN(bookingId), payload),
|
||||
|
||||
uploadGlDocuments: async (
|
||||
bookingId: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<{ uploaded: number; completedMilestones: string[] }> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.BOOKING_GL_DOCUMENTS(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as {
|
||||
uploaded: number;
|
||||
completedMilestones: string[];
|
||||
};
|
||||
},
|
||||
|
||||
listIncidents: async (
|
||||
bookingId: string,
|
||||
): Promise<Freight.IClearanceIncident[]> => {
|
||||
const response = await client.get(C.BOOKING_INCIDENTS(bookingId));
|
||||
return (unwrap(response.data) ?? []) as Freight.IClearanceIncident[];
|
||||
},
|
||||
|
||||
reportIncident: async (
|
||||
bookingId: string,
|
||||
payload: {
|
||||
incidentType: Freight.IncidentType;
|
||||
description: string;
|
||||
photos: File[];
|
||||
},
|
||||
): Promise<Freight.IClearanceIncident> => {
|
||||
const form = new FormData();
|
||||
form.append("incidentType", payload.incidentType);
|
||||
form.append("description", payload.description);
|
||||
for (const photo of payload.photos) form.append("photos", photo);
|
||||
const response = await client.post(C.BOOKING_INCIDENTS(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IClearanceIncident;
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
IOverviewBillingTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewCustomersTab,
|
||||
IOverviewDashboard,
|
||||
IOverviewOperationsTab,
|
||||
@@ -28,6 +29,13 @@ export const overviewService = {
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getContractsTab: async (range?: OverviewRange): Promise<IOverviewContractsTab> => {
|
||||
const response = await client.get<IOverviewContractsTab>(O.CONTRACTS, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
|
||||
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
|
||||
params: range ? { range } : undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
@@ -131,6 +132,24 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data).days;
|
||||
},
|
||||
|
||||
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
|
||||
// is serialized as a JSON string param (the server parses it).
|
||||
getAvailableDaysForCargo: async (
|
||||
query: Freight.AvailableDaysForCargoQuery,
|
||||
): Promise<string[]> => {
|
||||
const { containers, ...rest } = query;
|
||||
const response = await client.get<{ days: string[] }>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
|
||||
{
|
||||
params: {
|
||||
...rest,
|
||||
...(containers ? { containers: JSON.stringify(containers) } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return unwrap(response.data).days;
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface BookingContainerLine {
|
||||
code?: string;
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
isReefer?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export type {
|
||||
IOverviewDashboard,
|
||||
IOverviewKpis,
|
||||
IOverviewBookingKpis,
|
||||
IOverviewContractKpis,
|
||||
IOverviewOperationsKpis,
|
||||
IOverviewCustomerKpis,
|
||||
IOverviewBillingKpis,
|
||||
@@ -11,10 +12,12 @@ export type {
|
||||
IOverviewPipelineCount,
|
||||
IOverviewPaymentTrendPoint,
|
||||
IOverviewRecentBooking,
|
||||
IOverviewRecentContract,
|
||||
IOverviewLabelCount,
|
||||
IOverviewPaymentMethodBreakdown,
|
||||
IOverviewCurrencyAmount,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewBillingTab,
|
||||
IOverviewOperationsTab,
|
||||
IOverviewCustomersTab,
|
||||
|
||||
Reference in New Issue
Block a user