mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
CalendarCheck,
|
||||
Home,
|
||||
Layers,
|
||||
Loader2,
|
||||
@@ -36,10 +35,12 @@ import BillingPage from "./pages/billing/BillingPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
import NewContractPage from "./pages/contracts/NewContractPage";
|
||||
import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
@@ -177,12 +178,7 @@ function LandingRoute() {
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
|
||||
{
|
||||
label: "My Bookings",
|
||||
href: "/bookings",
|
||||
icon: <CalendarCheck size={18} />,
|
||||
},
|
||||
{
|
||||
label: "General Contracts",
|
||||
label: "Contracts",
|
||||
href: "/contracts",
|
||||
icon: <Layers size={18} />,
|
||||
},
|
||||
@@ -257,8 +253,16 @@ const App = () => {
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||
{/* Bookings live under contracts now — the standalone list is gone.
|
||||
Legacy /bookings* entry points redirect into the contract flow. */}
|
||||
<Route
|
||||
path="/bookings"
|
||||
element={<Navigate to="/contracts" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
@@ -266,6 +270,19 @@ const App = () => {
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/clearance"
|
||||
element={<ContractClearanceFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
|
||||
@@ -122,16 +122,19 @@ function getActivePage(
|
||||
|
||||
const navClassNames = (active: boolean) => {
|
||||
if (active) {
|
||||
// Active tab gets the strong brand color: a green gradient pill, white
|
||||
// label + icon, and a soft lifted shadow so it clearly stands out from the
|
||||
// light rail around it.
|
||||
return {
|
||||
root: `rounded-[10px] font-medium transition-all duration-150 bg-[#ECF6F1]! ring-1 ring-inset ring-[#0EA371]/15`,
|
||||
label: `text-[#0A6F4D]! font-bold!`,
|
||||
section: `text-[#0A6F4D]!`,
|
||||
root: `rounded-[12px] font-medium transition-all duration-150 bg-gradient-to-r from-[#0EA371] to-[#0A8A60]! shadow-[0_6px_16px_rgba(14,163,113,0.30)]!`,
|
||||
label: `text-white! font-bold!`,
|
||||
section: `text-white!`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
root: `rounded-[10px] font-medium transition-all duration-150 hover:bg-[#F1F4F7]!`,
|
||||
label: `text-edr-text! font-semibold! hover:text-[#0C1A2B]!`,
|
||||
section: `text-edr-text! hover:text-[#0C1A2B]!`,
|
||||
root: `rounded-[12px] font-medium transition-all duration-150 hover:bg-[#EBF4EF]!`,
|
||||
label: `text-edr-text! font-semibold! hover:text-[#0A6F4D]!`,
|
||||
section: `text-[#64748B]! hover:text-[#0A6F4D]!`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -157,7 +160,6 @@ export function AppLayout({
|
||||
const mutedColor = theme.colors["edr-muted"][6];
|
||||
const textColor = theme.colors["edr-text"][6];
|
||||
const accentColor = theme.colors["edr-accent"][6];
|
||||
const bgColor = theme.colors["edr-bg"][6];
|
||||
const primaryColor = theme.colors["edr-green"][5];
|
||||
const primaryDarkColor = theme.colors["edr-green"][7];
|
||||
|
||||
@@ -251,11 +253,11 @@ export function AppLayout({
|
||||
<AppShell.Header
|
||||
withBorder={false}
|
||||
style={{
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
background: "transparent",
|
||||
// Solid white surface with a hairline base and a soft drop so it
|
||||
// floats above the content area.
|
||||
background: "#FFFFFF",
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
boxShadow: "0 1px 12px rgba(16,24,40,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
@@ -506,7 +508,8 @@ export function AppLayout({
|
||||
<AppShell.Navbar
|
||||
withBorder={false}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
// Clean white rail.
|
||||
background: "#FFFFFF",
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -584,15 +587,15 @@ export function AppLayout({
|
||||
item.section && item.section !== prevSection ? (
|
||||
<Text
|
||||
key={`section-${item.section}`}
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mt={i === 0 ? 4 : "md"}
|
||||
mb={4}
|
||||
mt={i === 0 ? 6 : "lg"}
|
||||
mb={6}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#94A3B8",
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
>
|
||||
{item.section}
|
||||
@@ -793,11 +796,7 @@ export function AppLayout({
|
||||
{/* ── Main ── */}
|
||||
<AppShell.Main
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage:
|
||||
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.0.8) 38%, rgba(14,163,113,0.04) 72%)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundAttachment: "fixed",
|
||||
backgroundColor: "#F1F5F9",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -108,9 +108,34 @@ export const URL_CONSTANTS = {
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
BASE: "/api/contracts",
|
||||
MY: "/api/contracts/my",
|
||||
BY_ID: (id: string) => `/api/contracts/${id}`,
|
||||
DOCUMENTS: (id: string) => `/api/contracts/${id}/documents`,
|
||||
GENERATE_PRICE: (id: string) => `/api/contracts/${id}/generate-price`,
|
||||
SUBMIT: (id: string) => `/api/contracts/${id}/submit`,
|
||||
CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`,
|
||||
CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
|
||||
CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`,
|
||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||
`/api/contracts/${id}/clearance/documents`,
|
||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
||||
},
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
24
apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
Normal file
24
apps/edr-freight-web/portal/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 };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
HelloSection,
|
||||
InvoicesSection,
|
||||
RecentActivitySection,
|
||||
RecentContractsSection,
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
} from "./components";
|
||||
@@ -23,6 +24,9 @@ export default function MyPortalPage() {
|
||||
companyProfiles,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
allBookings,
|
||||
activeBookings,
|
||||
newActiveThisWeek,
|
||||
@@ -66,9 +70,11 @@ export default function MyPortalPage() {
|
||||
)}
|
||||
|
||||
<StatsSection
|
||||
activeContractsCount={activeContractsCount}
|
||||
activeBookingsLength={activeBookings.length}
|
||||
newActiveThisWeek={newActiveThisWeek}
|
||||
bookingsLoading={bookingsQuery.isPending}
|
||||
contractsLoading={contractsQuery.isPending}
|
||||
outstandingInvoicesLength={outstandingInvoices.length}
|
||||
totalOutstanding={totalOutstanding}
|
||||
deliveredCount={dashboard?.deliveredCount.toString()}
|
||||
@@ -85,9 +91,28 @@ export default function MyPortalPage() {
|
||||
dashboardLoading={dashboardQuery.isPending}
|
||||
/>
|
||||
|
||||
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<RecentContractsSection
|
||||
contracts={recentContracts}
|
||||
bookings={allBookings}
|
||||
isLoading={contractsQuery.isPending}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<ShipmentsSection
|
||||
bookings={allBookings.slice(0, 6)}
|
||||
isLoading={bookingsQuery.isPending}
|
||||
onBookingClick={handleBookingClick}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ShipmentsSection
|
||||
<RecentActivitySection
|
||||
bookings={allBookings}
|
||||
isLoading={bookingsQuery.isPending}
|
||||
onBookingClick={handleBookingClick}
|
||||
@@ -100,7 +125,7 @@ export default function MyPortalPage() {
|
||||
</Grid>
|
||||
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Grid.Col span={{ base: 12 }}>
|
||||
<FreightVolumeSection
|
||||
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
|
||||
totalValue={dashboard?.freightVolume.totalValue ?? 0}
|
||||
@@ -111,14 +136,6 @@ export default function MyPortalPage() {
|
||||
isLoading={dashboardQuery.isPending}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<RecentActivitySection
|
||||
bookings={allBookings}
|
||||
isLoading={bookingsQuery.isPending}
|
||||
onBookingClick={handleBookingClick}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
126
apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts
Normal file
126
apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
/** What the customer must do — drives the icon, label and modal. */
|
||||
kind: "clearance" | "sign" | "book" | "pay";
|
||||
/** The contract/booking reference for display. */
|
||||
reference: string;
|
||||
/** Short human description of the action. */
|
||||
description: string;
|
||||
/** Contract id (clearance/sign/book) or booking id (pay). */
|
||||
targetId: string;
|
||||
/** True for queried clearance (a document was sent back for correction). */
|
||||
urgent?: boolean;
|
||||
}
|
||||
|
||||
// Contract statuses that mean clearance is in progress (Path A or B). A queried
|
||||
// document flips the contract back to AWAITING_CLEARANCE_DOCUMENTS, but the panel
|
||||
// also allows re-upload while UNDER_REVIEW — so surface both as actionable.
|
||||
const CLEARANCE_IN_PROGRESS_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a contract has a clearance step that needs the customer to upload /
|
||||
* re-upload documents. Uses contract status AND clearanceStatus so a query is
|
||||
* caught even if only one field reflects it. Excludes the ready / completed gates.
|
||||
*/
|
||||
function contractNeedsClearance(c: Freight.IContract): {
|
||||
show: boolean;
|
||||
urgent: boolean;
|
||||
} {
|
||||
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
|
||||
const ready =
|
||||
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
clearance === "SELF_CLEARED" ||
|
||||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
if (ready) return { show: false, urgent: false };
|
||||
|
||||
const awaiting =
|
||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
||||
clearance === "AWAITING_DOCUMENTS";
|
||||
const inProgress =
|
||||
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
|
||||
clearance === "AWAITING_DOCUMENTS" ||
|
||||
clearance === "DOCUMENTS_UNDER_REVIEW";
|
||||
|
||||
return { show: inProgress, urgent: awaiting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the list of pending customer actions from the customer's contracts and
|
||||
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
|
||||
* re-upload after a query) is flagged urgent so the home card shows an upload
|
||||
* button. See {@link contractNeedsClearance}.
|
||||
*/
|
||||
export function deriveActionItems(
|
||||
contracts: Freight.IContract[],
|
||||
bookings: Freight.IBooking[],
|
||||
): ActionItem[] {
|
||||
const items: ActionItem[] = [];
|
||||
|
||||
for (const c of contracts) {
|
||||
if (c.status === "CONTRACT_READY") {
|
||||
items.push({
|
||||
id: `sign-${c.id}`,
|
||||
kind: "sign",
|
||||
reference: c.reference,
|
||||
description: "Contract ready to sign",
|
||||
targetId: c.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const clr = contractNeedsClearance(c);
|
||||
if (clr.show) {
|
||||
items.push({
|
||||
id: `clearance-${c.id}`,
|
||||
kind: "clearance",
|
||||
reference: c.reference,
|
||||
description: clr.urgent
|
||||
? "Clearance documents need your action"
|
||||
: "Clearance under review",
|
||||
targetId: c.id,
|
||||
urgent: clr.urgent,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Path A transport-only: customer may create the shipment booking.
|
||||
if (
|
||||
!c.customsClearingEnabled &&
|
||||
(c.status === "FULLY_EXECUTED" || c.status === "CONTRACT_ACTIVE")
|
||||
) {
|
||||
items.push({
|
||||
id: `book-${c.id}`,
|
||||
kind: "book",
|
||||
reference: c.reference,
|
||||
description: "Ready to book a shipment",
|
||||
targetId: c.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of bookings) {
|
||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||
const canPay =
|
||||
b.paymentStatus !== "PAID" &&
|
||||
(isGeneral
|
||||
? b.status === "FULLY_EXECUTED"
|
||||
: b.status === "SELECTED_FOR_BATCH");
|
||||
if (canPay) {
|
||||
items.push({
|
||||
id: `pay-${b.id}`,
|
||||
kind: "pay",
|
||||
reference: b.reference,
|
||||
description: "Payment due for this shipment",
|
||||
targetId: b.id,
|
||||
urgent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the most pressing (urgent) actions first.
|
||||
return items.sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
||||
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
FilePlus2,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { Card } from "./Card";
|
||||
import type { ActionItem } from "../actions";
|
||||
|
||||
// Contracts whose clearance is still in progress — candidates for a real
|
||||
// per-document query check (small set; only contracts awaiting/under review).
|
||||
const CLEARANCE_CANDIDATE_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
];
|
||||
|
||||
const KIND_META: Record<
|
||||
ActionItem["kind"],
|
||||
{ icon: typeof Upload; label: string; color: string }
|
||||
> = {
|
||||
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||
};
|
||||
|
||||
export interface ActionNeededSectionProps {
|
||||
/** Non-clearance actions (sign / book / pay) derived from status. */
|
||||
items: ActionItem[];
|
||||
/** All of the customer's contracts — used to detect real clearance queries. */
|
||||
contracts: Freight.IContract[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Home "needs your attention" card. Lists pending customer actions across
|
||||
* contracts and bookings. Clearance items are derived from the ACTUAL clearance
|
||||
* documents (so an open query always surfaces an upload button), payment + the
|
||||
* clearance upload open in a modal right here; sign and book navigate.
|
||||
*/
|
||||
export function ActionNeededSection({
|
||||
items: baseItems,
|
||||
contracts,
|
||||
}: ActionNeededSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
||||
const [payItem, setPayItem] = useState<ActionItem | null>(null);
|
||||
|
||||
// Fetch the clearance view for every contract still in a clearance phase, so we
|
||||
// can detect a queried document precisely (status alone can be ambiguous).
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
contracts.filter((c) =>
|
||||
CLEARANCE_CANDIDATE_STATUSES.includes(c.status),
|
||||
),
|
||||
[contracts],
|
||||
);
|
||||
|
||||
const clearanceQueries = useQueries({
|
||||
queries: candidates.map((c) =>
|
||||
api.contracts.getClearance.queryOptions({ input: { id: c.id } }),
|
||||
),
|
||||
});
|
||||
|
||||
// Build clearance action items from the fetched views: show whenever the
|
||||
// customer can still upload (not yet ready for booking), flag urgent + show the
|
||||
// query count when any document was sent back for correction.
|
||||
const clearanceItems = useMemo<ActionItem[]>(() => {
|
||||
const out: ActionItem[] = [];
|
||||
candidates.forEach((c, i) => {
|
||||
const view = clearanceQueries[i]?.data;
|
||||
const docs = view?.documents ?? [];
|
||||
const customerDocs = docs.filter((d) => d.uploadedBy === "customer");
|
||||
const queried = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "QUERIED",
|
||||
).length;
|
||||
const ready =
|
||||
view?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
view?.clearanceStatus === "SELF_CLEARED" ||
|
||||
view?.clearanceStatus === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
if (ready) return;
|
||||
const awaiting =
|
||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
||||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
|
||||
// Only surface when there's something the customer can do: a query, or the
|
||||
// contract is awaiting their (re)upload.
|
||||
if (queried === 0 && !awaiting) return;
|
||||
out.push({
|
||||
id: `clearance-${c.id}`,
|
||||
kind: "clearance",
|
||||
reference: c.reference,
|
||||
description:
|
||||
queried > 0
|
||||
? `${queried} document${queried > 1 ? "s" : ""} need correction`
|
||||
: "Clearance documents needed",
|
||||
targetId: c.id,
|
||||
urgent: queried > 0 || awaiting,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}, [candidates, clearanceQueries]);
|
||||
|
||||
// Merge: clearance items (from real docs) + the status-derived sign/book/pay.
|
||||
const items = useMemo(
|
||||
() => [...clearanceItems, ...baseItems.filter((i) => i.kind !== "clearance")],
|
||||
[clearanceItems, baseItems],
|
||||
).sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
|
||||
|
||||
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
|
||||
// redirect (clientAction.url); fall back to the public checkout page.
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.payments.initiate.call({ bookingId: payItem!.targetId, method }),
|
||||
onSuccess: (data, method) => {
|
||||
const bookingId = payItem!.targetId;
|
||||
const url =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId, method });
|
||||
window.location.href = url;
|
||||
},
|
||||
});
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const handleClick = (item: ActionItem) => {
|
||||
switch (item.kind) {
|
||||
case "clearance":
|
||||
setClearanceId(item.targetId);
|
||||
break;
|
||||
case "pay":
|
||||
setPayItem(item);
|
||||
break;
|
||||
case "sign":
|
||||
navigate(`/contracts/${item.targetId}/view`);
|
||||
break;
|
||||
case "book":
|
||||
navigate(`/contracts/${item.targetId}/bookings/new`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding={0}>
|
||||
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
|
||||
<Group gap={8}>
|
||||
<AlertTriangle size={18} className="text-amber-500" />
|
||||
<Text fw={700} fz={16} c="edr-text">
|
||||
Needs your attention
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color="orange" variant="light" radius="sm">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={0}>
|
||||
{items.map((item, i) => {
|
||||
const meta = KIND_META[item.kind];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={14}
|
||||
style={{
|
||||
borderTop:
|
||||
i === 0
|
||||
? "none"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleClick(item)}
|
||||
className="hover:bg-edr-soft"
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: `var(--mantine-color-${meta.color}-light)`,
|
||||
color: `var(--mantine-color-${meta.color}-filled)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||
{item.reference}
|
||||
</Text>
|
||||
{item.urgent && (
|
||||
<Badge size="xs" color="red" variant="light" radius="sm">
|
||||
Action required
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" truncate>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={item.urgent ? "filled" : "light"}
|
||||
color={meta.color}
|
||||
radius="md"
|
||||
leftSection={
|
||||
item.kind === "clearance" ? (
|
||||
<Upload size={14} />
|
||||
) : (
|
||||
<FilePlus2 size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay now"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: item.urgent
|
||||
? "Upload documents"
|
||||
: "Upload"}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={clearanceId !== null}
|
||||
onClose={() => setClearanceId(null)}
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Clearance documents
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
radius="md"
|
||||
centered
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
{clearanceId && (
|
||||
<ContractClearancePanel contractId={clearanceId} bare />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payItem !== null}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayItem(null);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
currency={undefined}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { ArrowRight, Truck } from "lucide-react";
|
||||
import { ArrowRight, FileSignature } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cv } from "../constants";
|
||||
@@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Link to="/bookings/new" state={{ fresh: true }}>
|
||||
<Link to="/contracts/new" state={{ fresh: true }}>
|
||||
<Group
|
||||
gap={14}
|
||||
align="center"
|
||||
@@ -36,11 +36,11 @@ export const HelloSection = memo(function HelloSection({
|
||||
py={14}
|
||||
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
|
||||
>
|
||||
<Truck size={22} color="#fff" />
|
||||
<FileSignature size={22} color="#fff" />
|
||||
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fz={14} fw={700} c="white" lh={1.3}>
|
||||
Book a shipment
|
||||
Create a contract
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const RecentActivitySection = memo(function RecentActivitySection({
|
||||
<Text fz={17} fw={700} c="edr-text">
|
||||
Recent Activity
|
||||
</Text>
|
||||
<Link to="/bookings">
|
||||
<Link to="/contracts">
|
||||
<Group gap={3} align="center" className="no-underline">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
View all
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, FileSignature, Package, Plus, RefreshCw } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
ContractDocButton,
|
||||
ContractStatusBadge,
|
||||
} from "@/pages/contracts/contract-ui";
|
||||
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
||||
import { Card } from "./Card";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
interface RecentContractsSectionProps {
|
||||
contracts: Freight.IContract[];
|
||||
bookings: Freight.IBooking[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const RecentContractsSection = memo(function RecentContractsSection({
|
||||
contracts,
|
||||
bookings,
|
||||
isLoading,
|
||||
}: RecentContractsSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Card className="h-full" padding={28}>
|
||||
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||
<Box>
|
||||
<Text fz={19} fw={800} c="edr-text">
|
||||
Recent Contracts
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Your freight agreements — ship against them after signing
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap={6}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height={60} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : contracts.length === 0 ? (
|
||||
<EmptyState message="No contracts yet. Create one to get started." />
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{contracts.map((c) => {
|
||||
const isGeneral = c.contractKind === "GENERAL";
|
||||
const isContainer = c.freightType === "CONTAINER";
|
||||
const canSign = c.status === "CONTRACT_READY";
|
||||
const bookingAction = getContractBookingAction(c, bookings);
|
||||
return (
|
||||
<Group
|
||||
key={c.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap={12}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${BORDER}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => navigate(`/contracts/${c.id}`)}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{c.reference}
|
||||
</Text>
|
||||
<Text fz={12} style={{ color: MUTED }} truncate>
|
||||
{isGeneral ? "General" : "One-Time"} ·{" "}
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<ContractDocButton
|
||||
contract={c}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<ContractStatusBadge status={c.status} />
|
||||
{canSign ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/contracts/${c.id}`);
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
</Button>
|
||||
) : bookingAction.kind !== "none" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
bookingAction.kind === "rebook" ? (
|
||||
<RefreshCw size={13} />
|
||||
) : (
|
||||
<Package size={13} />
|
||||
)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(bookingAction.to);
|
||||
}}
|
||||
>
|
||||
{bookingAction.kind === "rebook" ? "Re-book" : "Book"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
rightSection={<ArrowRight size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/contracts/${c.id}`);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
|
||||
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { formatPct } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
import { StatKpi } from "./StatKpi";
|
||||
|
||||
interface StatsSectionProps {
|
||||
activeContractsCount: number;
|
||||
activeBookingsLength: number;
|
||||
newActiveThisWeek: number;
|
||||
bookingsLoading: boolean;
|
||||
contractsLoading: boolean;
|
||||
outstandingInvoicesLength: number;
|
||||
totalOutstanding: number;
|
||||
deliveredCount: string | undefined;
|
||||
@@ -20,9 +22,11 @@ interface StatsSectionProps {
|
||||
}
|
||||
|
||||
export const StatsSection = memo(function StatsSection({
|
||||
activeContractsCount,
|
||||
activeBookingsLength,
|
||||
newActiveThisWeek,
|
||||
bookingsLoading,
|
||||
contractsLoading,
|
||||
outstandingInvoicesLength,
|
||||
totalOutstanding,
|
||||
deliveredCount,
|
||||
@@ -37,9 +41,17 @@ export const StatsSection = memo(function StatsSection({
|
||||
className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]"
|
||||
>
|
||||
<SimpleGrid
|
||||
cols={{ base: 2, lg: 4 }}
|
||||
cols={{ base: 2, md: 3, lg: 5 }}
|
||||
spacing={{ base: 20, lg: 0 }}
|
||||
>
|
||||
<StatKpi
|
||||
icon={Layers}
|
||||
accent="green"
|
||||
label="Active Contracts"
|
||||
value={activeContractsCount.toString()}
|
||||
delta=""
|
||||
loading={contractsLoading}
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Truck}
|
||||
accent="green"
|
||||
@@ -47,6 +59,7 @@ export const StatsSection = memo(function StatsSection({
|
||||
value={activeBookingsLength.toString()}
|
||||
delta={newActiveThisWeek > 0 ? `+${newActiveThisWeek} this week` : ""}
|
||||
loading={bookingsLoading}
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Clock3}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ActionNeededSection } from "./ActionNeededSection";
|
||||
export { ActivityRow } from "./ActivityRow";
|
||||
export { BookingRow } from "./BookingRow";
|
||||
export { Card } from "./Card";
|
||||
@@ -6,6 +7,7 @@ export { FreightVolumeSection } from "./FreightVolumeSection";
|
||||
export { HelloSection } from "./HelloSection";
|
||||
export { InvoicesSection } from "./InvoicesSection";
|
||||
export { RecentActivitySection } from "./RecentActivitySection";
|
||||
export { RecentContractsSection } from "./RecentContractsSection";
|
||||
export { ShipmentsSection } from "./ShipmentsSection";
|
||||
export { StatKpi } from "./StatKpi";
|
||||
export { StatsSection } from "./StatsSection";
|
||||
|
||||
@@ -25,6 +25,29 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
|
||||
);
|
||||
|
||||
const contractsQuery = useQuery(
|
||||
api.contracts.list.queryOptions({
|
||||
input: {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const allContracts = contractsQuery.data?.items ?? [];
|
||||
const recentContracts = allContracts.slice(0, 5);
|
||||
|
||||
const ACTIVE_CONTRACT_STATUSES = [
|
||||
"CONTRACT_ACTIVE",
|
||||
"FULLY_EXECUTED",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
];
|
||||
const activeContractsCount = allContracts.filter((c) =>
|
||||
ACTIVE_CONTRACT_STATUSES.includes(c.status),
|
||||
).length;
|
||||
|
||||
const allBookings = bookingsQuery.data?.items ?? [];
|
||||
const activeBookings = allBookings.filter((b) =>
|
||||
ACTIVE_STATUSES.includes(b.status),
|
||||
@@ -67,6 +90,10 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
companyProfiles,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
allContracts,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
allBookings,
|
||||
activeBookings,
|
||||
newActiveThisWeek,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -288,14 +289,16 @@ export function DraftBookingView({
|
||||
action={
|
||||
isUploaded && !allowReplace ? (
|
||||
<IconSquare
|
||||
href={file?.signedUrl ?? file?.url}
|
||||
href={file ? fileViewUrl(file.id, true) : undefined}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isUploaded && (
|
||||
<IconSquare
|
||||
href={file?.signedUrl ?? file?.url}
|
||||
href={
|
||||
file ? fileViewUrl(file.id, true) : undefined
|
||||
}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CreditCard, Download } from "lucide-react";
|
||||
import { CreditCard, Download, Eye } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -26,6 +29,7 @@ import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { PaymentCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
@@ -34,6 +38,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||
// New Contract page. Fall back to /contracts/new only if the link is missing.
|
||||
const rebookTo = booking.contractId
|
||||
? `/contracts/${booking.contractId}/bookings/new`
|
||||
: "/contracts/new";
|
||||
const onRebook = () => navigate(rebookTo);
|
||||
|
||||
// POST /payments/initiate creates the intent and returns the provider's
|
||||
// redirect URL (clientAction.url). Send the browser straight there; fall back
|
||||
@@ -93,7 +105,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
menuActions={{
|
||||
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
|
||||
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
|
||||
onRebook,
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
/>
|
||||
@@ -110,14 +122,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
: "This booking process has been terminated."
|
||||
}
|
||||
reason={booking.latestChangeRequestNote}
|
||||
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
|
||||
onRebook={onRebook}
|
||||
/>
|
||||
) : isExpired ? (
|
||||
<CancelledBanner
|
||||
pillLabel="Expired"
|
||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
|
||||
onRebook={onRebook}
|
||||
/>
|
||||
) : isPendingConsolidation ? (
|
||||
<ConsolidationWaitingBanner
|
||||
@@ -142,6 +154,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -159,10 +173,28 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
meta={file.code.replace(/_/g, " ")}
|
||||
status="verified"
|
||||
action={
|
||||
<IconSquare
|
||||
href={file.signedUrl ?? file.url}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={16} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={fileViewUrl(file.id, true)}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -213,6 +245,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, Group, Table, Text } from "@mantine/core";
|
||||
import { Boxes } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -22,10 +21,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Group gap={8} align="center">
|
||||
<Boxes size={18} color="#0A6F4D" />
|
||||
<CardTitle>Containers</CardTitle>
|
||||
</Group>
|
||||
<CardTitle>Containers</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{totalUnits} unit{totalUnits !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -52,52 +52,30 @@ export function ContractCard({
|
||||
radius={16}
|
||||
px={22}
|
||||
py={20}
|
||||
bg="#F1FAF6"
|
||||
style={{ border: "1px solid #CFEBDD" }}
|
||||
bg="white"
|
||||
style={{
|
||||
border: "1px solid #E6ECF2",
|
||||
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
|
||||
<Group gap={16} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 46,
|
||||
height: 46,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 13,
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #CDEBDD",
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
<Box miw={0}>
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
<FileSignature size={22} />
|
||||
</Box>
|
||||
<Box miw={0}>
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#0A6F4D",
|
||||
padding: "4px 10px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 0.3,
|
||||
color: "#fff",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
What's next
|
||||
</Box>
|
||||
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
|
||||
{c.title}
|
||||
</Text>
|
||||
<Text mt={2} fz="13px" c="#5B6B7A">
|
||||
{c.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
What's next
|
||||
</Text>
|
||||
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
|
||||
{c.title}
|
||||
</Text>
|
||||
<Text mt={2} fz="13px" c="#6B7C8E">
|
||||
{c.description}
|
||||
</Text>
|
||||
</Box>
|
||||
{c.buttonLabel && (
|
||||
<Button
|
||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||
|
||||
@@ -108,9 +108,12 @@ export function DocRow({
|
||||
export function IconSquare({
|
||||
icon,
|
||||
href,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
href?: string | null;
|
||||
/** When provided (and no href), renders a clickable button square. */
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
@@ -122,6 +125,7 @@ export function IconSquare({
|
||||
borderRadius: 8,
|
||||
border: "1px solid #E6ECF2",
|
||||
color: "#6B7C8E",
|
||||
cursor: href || onClick ? "pointer" : "default",
|
||||
};
|
||||
if (href) {
|
||||
return (
|
||||
@@ -136,6 +140,18 @@ export function IconSquare({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ ...style, background: "transparent" }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box component="span" style={style}>
|
||||
{icon}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { Box, Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import {
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
Package,
|
||||
Tag,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import { Box, SimpleGrid, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -20,41 +12,22 @@ type BookingLike = Freight.IBooking & {
|
||||
trainScheduleId?: string | null;
|
||||
};
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}) {
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
flexShrink: 0,
|
||||
background: "#F1F6FA",
|
||||
color: "#0A6F4D",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
<Box miw={0}>
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box miw={0}>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={4} fz="15px" fw={700} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,27 +46,20 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<SectionCard p="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 6 }} spacing="lg">
|
||||
<SectionCard p="lg">
|
||||
<SimpleGrid cols={{ base: 2, md: 3, xl: 6 }} spacing={0} verticalSpacing="lg">
|
||||
<Fact label="Type" value={isContract ? "General Contract" : "One-Time"} />
|
||||
<Fact label="Cargo" value={freight} />
|
||||
<Fact
|
||||
icon={<Tag size={17} />}
|
||||
label="Type"
|
||||
value={isContract ? "General Contract" : "One-Time"}
|
||||
/>
|
||||
<Fact icon={<Package size={17} />} label="Cargo" value={freight} />
|
||||
<Fact
|
||||
icon={<MapPin size={17} />}
|
||||
label="Route"
|
||||
value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`}
|
||||
/>
|
||||
<Fact icon={<CreditCard size={17} />} label="Payment" value={payment} />
|
||||
<Fact label="Payment" value={payment} />
|
||||
<Fact
|
||||
icon={<Train size={17} />}
|
||||
label="Train"
|
||||
value={booking.trainScheduleId ? "Assigned" : "Not assigned"}
|
||||
/>
|
||||
<Fact
|
||||
icon={<CalendarClock size={17} />}
|
||||
label={isContract ? "Ordering until" : "Scheduled"}
|
||||
value={
|
||||
isContract
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Check, Circle, Clock, Receipt, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard, CardTitle } from "./layout";
|
||||
|
||||
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
RED: "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only shipment tracking for the customer (Path B). Shows the GL milestone
|
||||
* progression and, when GL has advised duty/tax but the slip is not yet paid,
|
||||
* surfaces a payment-slip upload — the only customer action in this phase.
|
||||
*/
|
||||
export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { data: milestones = [] } = useQuery({
|
||||
queryKey: ["booking-milestones", bookingId],
|
||||
queryFn: () => contractsService.getBookingMilestones(bookingId),
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const uploadSlip = useMutation({
|
||||
mutationFn: (file: File) => contractsService.uploadDutySlip(bookingId, file),
|
||||
onSuccess: () => {
|
||||
setSlip(null);
|
||||
void qc.invalidateQueries({ queryKey: ["booking-milestones", bookingId] });
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...milestones].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[milestones],
|
||||
);
|
||||
const nextPending = sorted.find((m) => m.status === "PENDING");
|
||||
|
||||
const dutyAdvised = milestones.find(
|
||||
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
|
||||
);
|
||||
const dutyPaid = milestones.find((m) => m.milestoneCode === "DUTY_TAX_PAID");
|
||||
const needsDutySlip =
|
||||
dutyAdvised?.status === "COMPLETED" && dutyPaid?.status !== "COMPLETED";
|
||||
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Shipment tracking</CardTitle>
|
||||
|
||||
{needsDutySlip ? (
|
||||
<Box
|
||||
mt="sm"
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-yellow-3)",
|
||||
background: "var(--mantine-color-yellow-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} mb={6}>
|
||||
<Receipt size={16} />
|
||||
<Text fw={600} fz="sm">
|
||||
Duty & tax due
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
Global Logistics advised
|
||||
{dutyAdvised?.metadata?.dutyAmount != null
|
||||
? ` ${dutyAdvised.metadata.dutyAmount.toLocaleString()} ${dutyAdvised.metadata.dutyCurrency ?? ""}`
|
||||
: ""}
|
||||
{dutyAdvised?.metadata?.declarationSerial
|
||||
? ` · serial ${dutyAdvised.metadata.declarationSerial}`
|
||||
: ""}
|
||||
. Upload your payment slip to proceed.
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<FileButton onChange={setSlip} accept="application/pdf,image/*">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<Upload size={14} />}
|
||||
>
|
||||
{slip ? slip.name : "Choose slip"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={uploadSlip.isPending}
|
||||
disabled={!slip}
|
||||
onClick={() => slip && uploadSlip.mutate(slip)}
|
||||
>
|
||||
Upload slip
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack gap={0} mt="md">
|
||||
{sorted.map((m, index) => {
|
||||
const isLast = index === sorted.length - 1;
|
||||
const isNext = nextPending?.id === m.id;
|
||||
const Icon =
|
||||
m.status === "COMPLETED" ? Check : isNext ? Clock : Circle;
|
||||
const risk =
|
||||
m.milestoneCode === "RISK_ASSIGNED"
|
||||
? m.metadata?.riskLevel
|
||||
: undefined;
|
||||
|
||||
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" : "gray"}
|
||||
radius="xl"
|
||||
size={26}
|
||||
>
|
||||
<Icon size={13} strokeWidth={2.2} />
|
||||
</ThemeIcon>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 22,
|
||||
background:
|
||||
m.status === "COMPLETED"
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Box pb={isLast ? 0 : "sm"} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={m.status === "COMPLETED" ? 600 : 500}
|
||||
c={m.status === "COMPLETED" ? undefined : "dimmed"}
|
||||
>
|
||||
{m.milestoneLabel}
|
||||
</Text>
|
||||
{risk ? (
|
||||
<Badge size="xs" color={RISK_COLOR[risk]} variant="filled">
|
||||
{risk}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FileText,
|
||||
History,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
} from "lucide-react";
|
||||
import { Check, MoveRight } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -14,8 +7,6 @@ import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||
import { SectionCard } from "./layout";
|
||||
|
||||
const ACCENT = "#F2A516";
|
||||
|
||||
/** Origin → destination strip rendered above the progress tracker. */
|
||||
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
|
||||
const origin = yardLabel(booking.originYard);
|
||||
@@ -25,27 +16,14 @@ function RouteStrip({ booking }: { booking: Freight.IBooking }) {
|
||||
mb={22}
|
||||
px={18}
|
||||
py={14}
|
||||
className="rounded-2xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
|
||||
border: "1px solid #F2E4C4",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E6ECF2",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<RouteEndpoint label="Origin" value={origin} />
|
||||
<Box
|
||||
className="flex items-center justify-center rounded-full shrink-0"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: "#fff",
|
||||
border: `1px solid ${ACCENT}33`,
|
||||
color: ACCENT,
|
||||
}}
|
||||
>
|
||||
<MoveRight size={18} />
|
||||
</Box>
|
||||
<MoveRight size={18} color="#6B7C8E" className="shrink-0" />
|
||||
<RouteEndpoint label="Destination" value={destination} alignRight />
|
||||
</Group>
|
||||
</Box>
|
||||
@@ -63,24 +41,16 @@ function RouteEndpoint({
|
||||
}) {
|
||||
return (
|
||||
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify={alignRight ? "flex-end" : "flex-start"}
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
<MapPin size={12} color={ACCENT} />
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
c="#B07D14"
|
||||
tt="uppercase"
|
||||
className="tracking-[0.6px]"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={3} fz="15px" fw={700} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -99,21 +69,6 @@ export function StatusHero({
|
||||
const negative = isNegative(status);
|
||||
const draft = isDraftLike(status);
|
||||
|
||||
const tone: "green" | "slate" | "red" = negative
|
||||
? "red"
|
||||
: draft
|
||||
? "slate"
|
||||
: "green";
|
||||
const tileBg =
|
||||
tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1";
|
||||
const tileFg =
|
||||
tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371";
|
||||
const HeroIcon = negative
|
||||
? AlertTriangle
|
||||
: draft
|
||||
? FileText
|
||||
: (PROGRESS_STAGES[cfg.stage]?.icon ?? History);
|
||||
|
||||
const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled";
|
||||
const chipValue = fmtDate(
|
||||
draft || negative ? booking.updatedAt : booking.scheduledDate,
|
||||
@@ -121,52 +76,29 @@ export function StatusHero({
|
||||
|
||||
return (
|
||||
<SectionCard p={28}>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Group gap={16} align="center" wrap="nowrap">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-2xl shrink-0"
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
backgroundColor: tileBg,
|
||||
color: tileFg,
|
||||
}}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Text fz="21px" fw={800} c="#10202F">
|
||||
{cfg.title}
|
||||
</Text>
|
||||
<Text mt={5} fz="14px" c="#6B7C8E">
|
||||
{cfg.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box ta="right">
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
<HeroIcon size={26} />
|
||||
</div>
|
||||
<Box>
|
||||
<Text fz="21px" fw={800} c="#10202F">
|
||||
{cfg.title}
|
||||
</Text>
|
||||
<Text mt={5} fz="14px" c="#6B7C8E">
|
||||
{cfg.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group
|
||||
gap={11}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
className="rounded-[14px] border border-[#E6ECF2] bg-[#F7FAFC]"
|
||||
>
|
||||
<History size={22} color="#64748B" />
|
||||
<Box>
|
||||
<Text
|
||||
fz="10.5px"
|
||||
fw={700}
|
||||
c="#9AA8B5"
|
||||
tt="uppercase"
|
||||
className="tracking-[0.6px]"
|
||||
>
|
||||
{chipLabel}
|
||||
</Text>
|
||||
<Text fz="sm" fw={700} c="#10202F">
|
||||
{chipValue}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{chipLabel}
|
||||
</Text>
|
||||
<Text mt={3} fz="sm" fw={700} c="#10202F">
|
||||
{chipValue}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import { Box, Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { FileText, MessageSquare, XCircle } from "lucide-react";
|
||||
|
||||
export function SupportCard({ onCancel }: { onCancel?: () => void }) {
|
||||
return (
|
||||
<Paper radius={20} p={22} bg="#0C1A2B">
|
||||
<Group gap={12} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#16273A",
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={20} color="#fff" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="15px" fw={800} c="#fff">
|
||||
Need help?
|
||||
</Text>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
EDR operations team
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Text mt={14} fz="13px" c="#C4D0DB" style={{ lineHeight: 1.45 }}>
|
||||
<Paper
|
||||
radius={16}
|
||||
p={22}
|
||||
bg="white"
|
||||
style={{
|
||||
border: "1px solid #E6ECF2",
|
||||
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Need help?
|
||||
</Text>
|
||||
<Text mt={6} fz="15px" fw={700} c="#10202F">
|
||||
EDR operations team
|
||||
</Text>
|
||||
<Text mt={10} fz="13px" c="#6B7C8E" style={{ lineHeight: 1.45 }}>
|
||||
Questions about this shipment, documents, or delivery? Our operations
|
||||
team can help.
|
||||
</Text>
|
||||
@@ -43,14 +40,14 @@ export function SupportCard({ onCancel }: { onCancel?: () => void }) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="default"
|
||||
radius={10}
|
||||
color="#16273A"
|
||||
leftSection={
|
||||
onCancel ? <XCircle size={16} /> : <FileText size={16} />
|
||||
}
|
||||
styles={{
|
||||
root: { height: 44, paddingInline: 16 },
|
||||
label: { fontWeight: 700, color: "#fff" },
|
||||
label: { fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
{onCancel ? "Cancel" : "Report"}
|
||||
|
||||
@@ -38,9 +38,20 @@ interface SectionCardProps extends PaperProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function SectionCard({ children, ref, ...props }: SectionCardProps) {
|
||||
export function SectionCard({ children, ref, style, ...props }: SectionCardProps) {
|
||||
return (
|
||||
<Paper ref={ref} radius={20} p="lg" withBorder bg="white" {...props}>
|
||||
<Paper
|
||||
ref={ref}
|
||||
radius={16}
|
||||
p="lg"
|
||||
bg="white"
|
||||
style={{
|
||||
border: "1px solid #E6ECF2",
|
||||
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
...(style as object),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
@@ -48,7 +59,13 @@ export function SectionCard({ children, ref, ...props }: SectionCardProps) {
|
||||
|
||||
export function CardTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text fz="16px" fw={800} c="#10202F">
|
||||
<Text
|
||||
fz="11px"
|
||||
fw={700}
|
||||
c="#6B7C8E"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -948,7 +949,11 @@ export default function EditBookingPage() {
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isUploaded && !selected && (
|
||||
<IconSquare
|
||||
href={uploadedFile?.signedUrl ?? uploadedFile?.url}
|
||||
href={
|
||||
uploadedFile
|
||||
? fileViewUrl(uploadedFile.id, true)
|
||||
: undefined
|
||||
}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,884 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileEdit,
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "./booking-display";
|
||||
|
||||
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"COMPLETED",
|
||||
"DELIVERED",
|
||||
]);
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
statuses:
|
||||
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
|
||||
},
|
||||
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{
|
||||
key: "closed",
|
||||
label: "Cancelled / rejected",
|
||||
statuses: "CANCELLED,REJECTED",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
|
||||
value: f.key,
|
||||
label: f.label,
|
||||
}));
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
const STAT_CARDS: Array<{
|
||||
key: StatusFilterKey;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||
|
||||
function PrimaryAction({
|
||||
booking,
|
||||
onNavigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-edr-ink-0)",
|
||||
color: "#fff",
|
||||
}}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
|
||||
// modal (update & resubmit, upload clearance docs, schedule & proceed).
|
||||
if (bookingHasInlineAction(booking)) {
|
||||
return <BookingActionButton booking={booking} size="xs" />;
|
||||
}
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
if (status === payableStatus && booking.paymentStatus !== "PAID") {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={go}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
style={{
|
||||
letterSpacing: "0.6px",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
return data?.meta?.total;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
card,
|
||||
active,
|
||||
count,
|
||||
onSelect,
|
||||
}: {
|
||||
card: (typeof STAT_CARDS)[number];
|
||||
active: boolean;
|
||||
count: number | undefined;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Paper
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "box-shadow 140ms ease, border-color 140ms ease",
|
||||
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
|
||||
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: card.iconBg,
|
||||
color: card.iconColor,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={24} fw={800} lh={1.05} c="edr-text">
|
||||
{count ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{card.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyBookings() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const selectFilter = (key: StatusFilterKey) => {
|
||||
setStatusFilter(key);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
[
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
);
|
||||
const transitCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
|
||||
);
|
||||
const closedCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
active: activeCount,
|
||||
payment: paymentCount,
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: transitCount,
|
||||
closed: closedCount,
|
||||
};
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
const total = data?.meta?.total ?? allItems.length;
|
||||
|
||||
// Server handles status + pagination; reference search is applied on the page.
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allItems;
|
||||
return allItems.filter((b) =>
|
||||
[b.reference, b.originYard?.label, b.destinationYard?.label]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allItems, query]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
size: 244,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 9,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--mantine-color-edr-soft-0)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Package
|
||||
size={18}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text" truncate>
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{cargoLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 150,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Type" />,
|
||||
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
size: 168,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Cargo" />,
|
||||
cell: ({ row }) => <CargoModeCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
size: 196,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Route" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
||||
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{origin} → {dest}
|
||||
</Text>
|
||||
{sub && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
{sub}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
size: 130,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
},
|
||||
{
|
||||
id: "scheduling",
|
||||
size: 140,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Train" />,
|
||||
cell: ({ row }) => <SchedulingCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 190,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Status" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
size: 140,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original as Freight.IBooking & {
|
||||
totalAmount?: number;
|
||||
amount?: number;
|
||||
};
|
||||
const amount = b.totalAmount ?? b.amount ?? null;
|
||||
if (!amount) {
|
||||
return (
|
||||
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
ETB {amount.toLocaleString()}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: hMeta,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<Train size={14} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
||||
View details
|
||||
</Menu.Item>
|
||||
{trackable && (
|
||||
<Menu.Item
|
||||
leftSection={<Train size={15} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track shipment
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* ── Page header ─────────────────────────────────────────────── */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Group gap={10} align="center">
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
state={{ fresh: true }}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New booking
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
|
||||
{STAT_CARDS.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
card={card}
|
||||
active={statusFilter === card.key}
|
||||
count={cardCounts[card.key]}
|
||||
onSelect={() => selectFilter(card.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Bookings table card ──────────────────────────────────────── */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap={12}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
placeholder="Search reference or route…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
|
||||
/>
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) =>
|
||||
selectFilter((value as StatusFilterKey) ?? "all")
|
||||
}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 190 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any type"
|
||||
data={[
|
||||
{ value: "ONE_TIME", label: "One-time" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General contract" },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(v) => {
|
||||
setTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 170 }}
|
||||
aria-label="Filter by booking type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
setFreightFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 150 }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
onChange={(e) => {
|
||||
setCreatedFrom(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created from"
|
||||
placeholder="From"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdTo}
|
||||
onChange={(e) => {
|
||||
setCreatedTo(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created to"
|
||||
placeholder="To"
|
||||
/>
|
||||
{hasExtraFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{total} booking{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
mb="xs"
|
||||
>
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{query
|
||||
? "No bookings match your search"
|
||||
: "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
{query
|
||||
? "Try a different reference or clear the search."
|
||||
: "Create your first booking to get started."}
|
||||
</Text>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
state={{ fresh: true }}
|
||||
size="sm"
|
||||
mt="md"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/bookings/${(row as Freight.IBooking).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingBooking !== null}
|
||||
onClose={() => setTrackingBooking(null)}
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ??
|
||||
trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
trackingBooking?.destinationYard?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -13,14 +13,18 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { OperationDatePicker } from "./OperationDatePicker";
|
||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||
|
||||
@@ -104,6 +108,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
uploadMutation,
|
||||
proceedMutation,
|
||||
} = flow;
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
if (!clearance) return null;
|
||||
|
||||
@@ -164,8 +169,26 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<StatusPill doc={doc} />
|
||||
{doc.file &&
|
||||
isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{doc.file && (
|
||||
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
@@ -220,7 +243,10 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
{doc.label}
|
||||
</Text>
|
||||
{doc.file ? (
|
||||
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Pending
|
||||
@@ -310,6 +336,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
)}
|
||||
|
||||
{footer}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
import { Box, Button, Group, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OperationDatePicker as DatePicker } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
@@ -29,11 +11,9 @@ interface OperationDatePickerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact month calendar for picking the binding shipment day at the
|
||||
* operation-request step. Only days that have an OPEN scheduled departure on the
|
||||
* booking route are selectable; all other days are disabled.
|
||||
*
|
||||
* Shared by the booking detail clearance card and the home-page action modal.
|
||||
* Route-based day picker for the operation-request step: a thin query wrapper
|
||||
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
|
||||
* Only days with an OPEN scheduled departure on the route are selectable.
|
||||
*/
|
||||
export function OperationDatePicker({
|
||||
originYardId,
|
||||
@@ -41,8 +21,6 @@ export function OperationDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: OperationDatePickerProps) {
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||
|
||||
const { data: availableDays, isLoading } = useQuery(
|
||||
api.bookings.getAvailableDays.queryOptions({
|
||||
input: { originYardId, destinationYardId },
|
||||
@@ -50,170 +28,14 @@ export function OperationDatePicker({
|
||||
}),
|
||||
);
|
||||
|
||||
const departureDays = useMemo(
|
||||
() => new Set(availableDays ?? []),
|
||||
[availableDays],
|
||||
);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
|
||||
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
|
||||
return eachDayOfInterval({ start, end }).map((date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
return {
|
||||
date,
|
||||
dateString,
|
||||
day: date.getDate(),
|
||||
inMonth: isSameMonth(date, month),
|
||||
today: isToday(date),
|
||||
selected: value === dateString,
|
||||
hasDeparture: departureDays.has(dateString),
|
||||
};
|
||||
});
|
||||
}, [month, departureDays, value]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid #E6ECF2",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
maxWidth: 340,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
px={6}
|
||||
radius="xl"
|
||||
onClick={() => setMonth((m) => addMonths(m, -1))}
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</Button>
|
||||
<Text fz="13px" fw={700} c="#10202F">
|
||||
{format(month, "MMMM yyyy")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
px={6}
|
||||
radius="xl"
|
||||
onClick={() => setMonth((m) => addMonths(m, 1))}
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md" gap={8}>
|
||||
<CalendarIcon size={15} color="#9AA8B5" />
|
||||
<Text fz="12px" c="dimmed">
|
||||
Loading available days…
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
|
||||
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{cells.map((c) => {
|
||||
const clickable = c.hasDeparture && c.inMonth;
|
||||
return (
|
||||
<button
|
||||
key={c.dateString}
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={() => clickable && onChange(c.dateString)}
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
fontSize: 12.5,
|
||||
fontWeight: c.selected ? 800 : 600,
|
||||
cursor: clickable ? "pointer" : "default",
|
||||
border: c.selected
|
||||
? "1.5px solid #12B981"
|
||||
: clickable
|
||||
? "1px solid #CDEBDD"
|
||||
: "1px solid transparent",
|
||||
background: c.selected
|
||||
? "#12B981"
|
||||
: clickable
|
||||
? "#F4FBF7"
|
||||
: "transparent",
|
||||
color: c.selected
|
||||
? "#fff"
|
||||
: !c.inMonth
|
||||
? "#CBD5E1"
|
||||
: clickable
|
||||
? "#0A6F4D"
|
||||
: "#C4CDD6",
|
||||
transition: "all 120ms ease",
|
||||
}}
|
||||
>
|
||||
{c.day}
|
||||
{c.hasDeparture && c.inMonth && !c.selected && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
background: "#12B981",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{c.selected && (
|
||||
<Check
|
||||
size={11}
|
||||
color="#fff"
|
||||
strokeWidth={3}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 3,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{value && (
|
||||
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
|
||||
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||
</Text>
|
||||
)}
|
||||
{!isLoading && departureDays.size === 0 && (
|
||||
<Text fz="12px" c="orange.7" mt="sm">
|
||||
No scheduled departures found for this route yet.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<DatePicker
|
||||
availableDays={availableDays ?? []}
|
||||
isLoading={isLoading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OperationDatePicker;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Combobox, InputBase, Loader, Text, useCombobox } from "@mantine/core";
|
||||
import { MapPin, Search } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Combobox,
|
||||
Group,
|
||||
InputBase,
|
||||
Loader,
|
||||
Modal,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { Check, MapPin, Search } from "lucide-react";
|
||||
import L from "leaflet";
|
||||
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
|
||||
@@ -42,13 +52,43 @@ const PINNED_ZOOM = 14;
|
||||
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
|
||||
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
|
||||
// Search only fires once the user pauses typing for this long. Slightly longer
|
||||
// than a keystroke burst so we make one request per pause, not per character —
|
||||
// and it keeps us within Nominatim's 1 req/s fair-use limit.
|
||||
const SEARCH_DEBOUNCE_MS = 450;
|
||||
// than a keystroke burst so we make one request per pause, not per character.
|
||||
const SEARCH_DEBOUNCE_MS = 550;
|
||||
const MIN_QUERY_LEN = 2;
|
||||
// Bias geocoding toward the EDR corridor countries so local addresses surface
|
||||
// first (Nominatim still returns global matches if nothing local fits).
|
||||
const SEARCH_COUNTRYCODES = "et,dj";
|
||||
// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard
|
||||
// floor a touch above 1s so a flurry of map clicks / searches can never trip
|
||||
// the 429 ("Too Many Requests") wall.
|
||||
const MIN_REQUEST_INTERVAL_MS = 1100;
|
||||
// Reverse-geocode precision: coordinates are rounded to ~11m before caching so
|
||||
// near-identical pin drags resolve from cache instead of re-hitting the API.
|
||||
const REVERSE_COORD_PRECISION = 4;
|
||||
|
||||
// ── Module-level rate-limited request queue ─────────────────────────────────
|
||||
// Every Nominatim call (forward + reverse, across ALL picker instances on the
|
||||
// page) funnels through one promise chain that spaces requests ≥1.1s apart.
|
||||
let lastRequestAt = 0;
|
||||
let queueTail: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function scheduleRequest<T>(run: () => Promise<T>): Promise<T> {
|
||||
const result = queueTail.then(async () => {
|
||||
const now = Date.now();
|
||||
const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastRequestAt = Date.now();
|
||||
return run();
|
||||
});
|
||||
// Keep the chain alive even if this request rejects, so one failure doesn't
|
||||
// stall every queued request behind it.
|
||||
queueTail = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
|
||||
const searchCache = new Map<string, GeocodeResult[]>();
|
||||
const reverseCache = new Map<string, string>();
|
||||
|
||||
/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
|
||||
async function nominatimSearch(
|
||||
@@ -81,30 +121,58 @@ async function nominatimSearch(
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
|
||||
* local addresses rank highest, then fall back to a global search when nothing
|
||||
* local matches — so the field never looks "broken" for an out-of-region query.
|
||||
* Forward-geocode a free-text query. Served from cache when possible; otherwise
|
||||
* queued (rate-limited) and tried EDR-corridor-first, then global, so local
|
||||
* addresses rank highest without the field ever looking "broken".
|
||||
*/
|
||||
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
|
||||
const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
|
||||
if (local.length > 0) return local;
|
||||
return nominatimSearch(query, signal);
|
||||
async function searchPlaces(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<GeocodeResult[]> {
|
||||
const key = query.trim().toLowerCase();
|
||||
const cached = searchCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const found = await scheduleRequest(async () => {
|
||||
if (signal.aborted) return [];
|
||||
const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
|
||||
if (local.length > 0) return local;
|
||||
return nominatimSearch(query, signal);
|
||||
});
|
||||
if (found.length > 0) searchCache.set(key, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Reverse-geocode a dropped pin to its nearest address. */
|
||||
async function reverseGeocode(lat: number, lng: number): Promise<string> {
|
||||
/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */
|
||||
async function reverseGeocode(
|
||||
lat: number,
|
||||
lng: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed(
|
||||
REVERSE_COORD_PRECISION,
|
||||
)}`;
|
||||
const cached = reverseCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lng),
|
||||
format: "json",
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
const address = await scheduleRequest(async () => {
|
||||
if (signal?.aborted) return "";
|
||||
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
|
||||
signal,
|
||||
headers: { Accept: "application/json", "Accept-Language": "en" },
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { display_name?: string };
|
||||
return data.display_name ?? "";
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { display_name?: string };
|
||||
return data.display_name ?? "";
|
||||
reverseCache.set(key, address);
|
||||
return address;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -150,6 +218,12 @@ export interface LocationPickerProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
/**
|
||||
* "inline" (default) renders the search + map directly. "modal" renders a
|
||||
* compact read-only trigger that opens the map picker in a centered modal —
|
||||
* cleaner for forms with several mile sections stacked together.
|
||||
*/
|
||||
variant?: "inline" | "modal";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,19 +232,123 @@ export interface LocationPickerProps {
|
||||
* - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
|
||||
* Reports the resolved address and coordinates up via `onChange`.
|
||||
*/
|
||||
export function LocationPicker({
|
||||
export function LocationPicker(props: LocationPickerProps) {
|
||||
if (props.variant === "modal") return <LocationPickerModal {...props} />;
|
||||
return <LocationPickerInline {...props} />;
|
||||
}
|
||||
|
||||
/** Compact trigger + modal wrapper around the inline picker. */
|
||||
function LocationPickerModal({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Search an address or click the map…",
|
||||
error,
|
||||
}: LocationPickerProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const hasPin = value.lat != null && value.lng != null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="#10202F" mb={6}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box
|
||||
onClick={() => setOpened(true)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
minHeight: 46,
|
||||
padding: "8px 12px",
|
||||
border: `1px solid ${error ? "#E03131" : hasPin ? "#CDEBDD" : "#E6ECF2"}`,
|
||||
background: hasPin
|
||||
? "linear-gradient(135deg, #F6FBF8 0%, #FFFFFF 70%)"
|
||||
: "#fff",
|
||||
transition: "border-color 130ms ease, background 130ms ease",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
flexShrink: 0,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: hasPin ? "#ECF6F1" : "#F1F4F7",
|
||||
color: hasPin ? "#0A6F4D" : "#64748B",
|
||||
}}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
</Box>
|
||||
<Text fz={13.5} c={hasPin ? "#10202F" : "#94A3B8"} lineClamp={1} style={{ flex: 1 }}>
|
||||
{hasPin ? value.address || "Pinned location" : placeholder}
|
||||
</Text>
|
||||
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
|
||||
{hasPin ? "Change" : "Pick on map"}
|
||||
</Text>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text fz={12} c="red" mt={5}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={label}
|
||||
size="lg"
|
||||
centered
|
||||
radius="lg"
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.45 }}
|
||||
styles={{ title: { fontWeight: 700, color: "#10202F" } }}
|
||||
>
|
||||
<LocationPickerInline
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label=""
|
||||
placeholder={placeholder}
|
||||
mapHeight={320}
|
||||
withinPortal={false}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
disabled={!hasPin}
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** The original inline search + map experience. */
|
||||
function LocationPickerInline({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Search an address or click the map…",
|
||||
error,
|
||||
mapHeight = 260,
|
||||
withinPortal = true,
|
||||
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
||||
const combobox = useCombobox();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<GeocodeResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const reverseAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const hasPin = value.lat != null && value.lng != null;
|
||||
|
||||
@@ -212,6 +390,9 @@ export function LocationPicker({
|
||||
};
|
||||
}, [query, combobox]);
|
||||
|
||||
// Abort any in-flight reverse lookup when the picker unmounts.
|
||||
useEffect(() => () => reverseAbortRef.current?.abort(), []);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(r: GeocodeResult) => {
|
||||
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
|
||||
@@ -226,8 +407,13 @@ export function LocationPicker({
|
||||
async (lat: number, lng: number) => {
|
||||
// Show the pin immediately; fill the address once reverse geocoding lands.
|
||||
onChange({ address: value.address, lat, lng });
|
||||
// Cancel any in-flight reverse lookup — only the latest dropped pin counts.
|
||||
reverseAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
reverseAbortRef.current = controller;
|
||||
setResolving(true);
|
||||
const address = await reverseGeocode(lat, lng);
|
||||
const address = await reverseGeocode(lat, lng, controller.signal);
|
||||
if (controller.signal.aborted) return; // a newer pin superseded this one
|
||||
setResolving(false);
|
||||
onChange({
|
||||
address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
|
||||
@@ -246,10 +432,15 @@ export function LocationPicker({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Combobox store={combobox} withinPortal shadow="md" radius="md">
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={withinPortal}
|
||||
shadow="md"
|
||||
radius="md"
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={label}
|
||||
label={label || undefined}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
error={error}
|
||||
@@ -297,7 +488,7 @@ export function LocationPicker({
|
||||
<Box
|
||||
mt={10}
|
||||
style={{
|
||||
height: 260,
|
||||
height: mapHeight,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
border: "1px solid #E6ECF2",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Real CSS for the shared form fields. Mantine v7's `styles` prop only accepts
|
||||
* flat properties — pseudo-classes and `&[data-...]` attribute selectors there
|
||||
* are ignored (and `&[data-...]` logs an "Unsupported style property" warning).
|
||||
* Those interactive states live here and are wired via `classNames` (see
|
||||
* `fieldClassNames` in `shared.tsx`).
|
||||
*/
|
||||
|
||||
.edrFieldInput:hover {
|
||||
border-color: #cbd8e4;
|
||||
}
|
||||
|
||||
.edrFieldInput:focus,
|
||||
.edrFieldInput:focus-within {
|
||||
border-color: #0ea371;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(14, 163, 113, 0.13),
|
||||
0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.edrFieldOption[data-combobox-selected] {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(14, 163, 113, 0.1),
|
||||
rgba(14, 163, 113, 0.05)
|
||||
);
|
||||
color: #0a6f4d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edrFieldOption[data-combobox-active] {
|
||||
background: #f1f6fa;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import type { BookingFormInputValues } from "./schema";
|
||||
import "./field-styles.css";
|
||||
|
||||
// Brand tokens (kept local so the form reads consistently with the booking
|
||||
// detail page and the scheduling step).
|
||||
@@ -267,10 +268,52 @@ export function StepHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared Mantine input styling so every field in the form matches. */
|
||||
/**
|
||||
* Shared Mantine input styling so every field in the form matches. Mantine v7's
|
||||
* `styles` prop accepts only flat properties, so interactive states (`:hover`,
|
||||
* `:focus`, `[data-combobox-selected]`…) live in `field-styles.css` and are
|
||||
* applied through `fieldClassNames` below — never as `&`-nested keys here.
|
||||
*/
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
minHeight: 46,
|
||||
height: 46,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: INK,
|
||||
borderColor: BORDER,
|
||||
background: "linear-gradient(180deg, #FFFFFF 0%, #FCFDFE 100%)",
|
||||
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition:
|
||||
"border-color 130ms ease, box-shadow 130ms ease, background 130ms ease",
|
||||
},
|
||||
section: { color: MUTED },
|
||||
dropdown: {
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${BORDER}`,
|
||||
boxShadow: "0 12px 32px rgba(16,24,40,0.12)",
|
||||
padding: 6,
|
||||
},
|
||||
option: {
|
||||
borderRadius: 9,
|
||||
fontSize: 13.5,
|
||||
fontWeight: 500,
|
||||
padding: "9px 10px",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Class names carrying the field's interactive states (hover/focus ring and
|
||||
* combobox selected/active option). Pair with `fieldStyles` on every Select /
|
||||
* InputBase so the look matches and no unsupported-selector warning is logged.
|
||||
*/
|
||||
export const fieldClassNames = {
|
||||
input: "edrFieldInput",
|
||||
option: "edrFieldOption",
|
||||
} as const;
|
||||
|
||||
export function SelectField({
|
||||
@@ -310,6 +353,7 @@ export function SelectField({
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
classNames={fieldClassNames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -362,6 +406,7 @@ export function AsyncComboboxField({
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
classNames={{ input: fieldClassNames.input }}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SmartFileInput } from "@edr/ui-common";
|
||||
import { CheckCircle2, Download, FileText } from "lucide-react";
|
||||
|
||||
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { labelForDocCode } from "./resubmitDocs";
|
||||
import type { ResubmitFlowController } from "./useResubmitFlow";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
|
||||
</Text>
|
||||
</Group>
|
||||
<IconSquare
|
||||
href={file.signedUrl ?? file.url}
|
||||
href={fileViewUrl(file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ContractStatusBadge, INK } from "./contract-ui";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
|
||||
/**
|
||||
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
|
||||
* upload logic live in {@link ContractClearancePanel} so the same workspace can
|
||||
* render here (full page) or inside the contract action modal.
|
||||
*/
|
||||
export default function ContractClearanceFlow() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: contract } = useQuery(
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
const { data: clearance } = useQuery(
|
||||
api.contracts.getClearance.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px={8}
|
||||
onClick={() => navigate(`/contracts/${id}`)}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</Button>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={48} radius="lg" variant="light" color="edr-green">
|
||||
<Upload size={23} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap={10} align="center">
|
||||
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
|
||||
Clearance documents
|
||||
</Title>
|
||||
{contract && <ContractStatusBadge status={contract.status} />}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{contract?.reference ?? ""} · Cycle{" "}
|
||||
{clearance?.cycleNumber ?? 1}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{id && <ContractClearancePanel contractId={id} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||
|
||||
const GREEN = "#0A6F4D";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
||||
if (doc.reviewStatus === "APPROVED") {
|
||||
return (
|
||||
<Group gap={6} c={GREEN}>
|
||||
<CheckCircle2 size={15} />
|
||||
<Text fz="12px" fw={600} c={GREEN}>
|
||||
Approved
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.reviewStatus === "QUERIED") {
|
||||
return (
|
||||
<Group gap={6} c="#C0392B">
|
||||
<AlertCircle size={15} />
|
||||
<Text fz="12px" fw={600} c="#C0392B">
|
||||
Query
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (doc.file) {
|
||||
return (
|
||||
<Text fz="12px" fw={600} c="#6B7C8E">
|
||||
Uploaded
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ContractClearancePanelProps {
|
||||
contractId: string;
|
||||
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clearance document workspace for a contract: shows the customer-input doc
|
||||
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
|
||||
* after first submission), and surfaces query notes. Rendered both on the
|
||||
* standalone clearance page and inside the contract action modal. See
|
||||
* docs/new-doc.md §8.3.
|
||||
*/
|
||||
export function ContractClearancePanel({
|
||||
contractId,
|
||||
bare,
|
||||
}: ContractClearancePanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const clearanceQuery = useQuery(
|
||||
api.contracts.getClearance.queryOptions({
|
||||
input: { id: contractId },
|
||||
enabled: !!contractId,
|
||||
}),
|
||||
);
|
||||
const clearance = clearanceQuery.data;
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
setAdHoc([]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
);
|
||||
// Surface queried documents (the ones needing correction) first.
|
||||
const rank = (s: string | null) =>
|
||||
s === "QUERIED" ? 0 : s === "APPROVED" ? 2 : 1;
|
||||
return [...docs].sort(
|
||||
(a, b) => rank(a.reviewStatus) - rank(b.reviewStatus),
|
||||
);
|
||||
}, [clearance]);
|
||||
|
||||
const queriedCount = useMemo(
|
||||
() => customerDocs.filter((d) => d.reviewStatus === "QUERIED").length,
|
||||
[customerDocs],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "SELF_CLEARED" ||
|
||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
const customsPath = clearance?.includesCustoms ?? true;
|
||||
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||
[customerDocs, pending],
|
||||
);
|
||||
|
||||
const hasStagedFiles =
|
||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
||||
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStagedFiles && missingRequired.length === 0
|
||||
: hasStagedFiles;
|
||||
|
||||
const stagePending = (fileKey: string, file: File) =>
|
||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
||||
|
||||
const submitDocuments = () => {
|
||||
const files: Record<string, File | null> = { ...pending };
|
||||
adHoc.forEach((row, i) => {
|
||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||
});
|
||||
if (Object.keys(files).length === 0) return;
|
||||
uploadMutation.mutate({ id: contractId, files });
|
||||
};
|
||||
|
||||
if (clearanceQuery.isLoading) {
|
||||
return (
|
||||
<Center mih={bare ? 200 : 400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const body = (
|
||||
<Stack gap={0}>
|
||||
{queriedCount > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
title={`${queriedCount} document${queriedCount > 1 ? "s" : ""} need correction`}
|
||||
>
|
||||
Re-upload the highlighted document{queriedCount > 1 ? "s" : ""} below to
|
||||
continue. The reviewer's note explains what to fix.
|
||||
</Alert>
|
||||
)}
|
||||
{isReady ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||
{customsPath
|
||||
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due."
|
||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||
</Alert>
|
||||
) : isUnderReview ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
|
||||
your documents. Only re-upload the documents flagged with a query
|
||||
below — approved documents stay as they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||
{customsPath
|
||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
||||
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required: {missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Required customer documents */}
|
||||
<Stack gap={10}>
|
||||
{customerDocs.map((doc) => (
|
||||
<Box
|
||||
key={doc.fileKey}
|
||||
className="rounded-xl"
|
||||
style={{
|
||||
border:
|
||||
doc.reviewStatus === "QUERIED"
|
||||
? "1px solid #F0B4B4"
|
||||
: `1px solid ${BORDER}`,
|
||||
background: doc.reviewStatus === "QUERIED" ? "#FDF4F4" : "#fff",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box c="#2E5B96">
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
{doc.file && (
|
||||
<Text fz="12px" c="dimmed" truncate>
|
||||
{doc.file.name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<StatusPill doc={doc} />
|
||||
{doc.file &&
|
||||
isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{doc.file && (
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||
<FileButton
|
||||
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
||||
<Text fz="12px" c="#C0392B" mt={6}>
|
||||
Query: {doc.note}
|
||||
</Text>
|
||||
)}
|
||||
{pending[doc.fileKey] && (
|
||||
<StagedFilePreview file={pending[doc.fileKey]} onPreview={view} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{customerDocs.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No clearance documents are configured for this contract yet.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* GL output documents (read-only). */}
|
||||
{glDocs.length > 0 && (
|
||||
<>
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||
Customs output documents
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group
|
||||
key={doc.fileKey}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
|
||||
>
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
{doc.label}
|
||||
</Text>
|
||||
{doc.file ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Pending
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ad-hoc / additional documents. */}
|
||||
{canUpload && (
|
||||
<Box mt="lg">
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<Text fz="12.5px" fw={700} c="#10202F">
|
||||
Additional documents
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
||||
>
|
||||
Add document
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
{adHoc.map((row, i) => (
|
||||
<Box key={i}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Document name"
|
||||
value={row.name}
|
||||
onChange={(e) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) =>
|
||||
j === i ? { ...r, name: e.currentTarget.value } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
setAdHoc((rows) =>
|
||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||
)
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button {...props} variant="default" radius="md">
|
||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
{row.file && (
|
||||
<StagedFilePreview file={row.file} onPreview={view} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||
{uploadMutation.error instanceof Error
|
||||
? uploadMutation.error.message
|
||||
: "Upload failed. Please try again."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canSubmit}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={submitDocuments}
|
||||
>
|
||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (bare) return body;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
||||
{body}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact preview chip for a locally-staged (not-yet-uploaded) clearance file.
|
||||
* Shows an image thumbnail (or a file glyph) plus a Preview button that opens the
|
||||
* file in the shared viewer via a local object URL. The URL is minted once per
|
||||
* File and 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"
|
||||
mt={8}
|
||||
p={8}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px dashed ${GREEN}`,
|
||||
background: "#F2FBF6",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<Box
|
||||
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${BORDER}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#E7F6EE",
|
||||
color: GREEN,
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fz="12px" fw={700} c={GREEN}>
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text fz="12px" c="#10202F" 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContractClearancePanel;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user