enhance contract management features

- Update ContractActionsToolbar to streamline contract generation and signing processes.
- Introduce getStaffRowAction for improved action handling in contract list rows.
- Implement auto-generation of contracts upon final approval in useContracts hook.
- Refactor BookingDetailPage to remove unnecessary container type property.
- Add action buttons in ContractRequestsPage for better user interaction.
- Simplify container types configuration in resources.
- Remove unused properties from booking types.
- Introduce ContractViewPage for staff and customer contract signing.
- Enhance NewContractPage to validate document uploads before proceeding.
- Improve StepDocuments to ensure required documents are uploaded.
This commit is contained in:
Marshal
2026-06-27 17:38:26 +00:00
parent 3dff7ec189
commit 0a23ade118
16 changed files with 793 additions and 152 deletions

View File

@@ -34,6 +34,7 @@ import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPag
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";
@@ -450,6 +451,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="contract-requests/:id/view"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
<ContractViewPage />
</RequirePermission>
}
/>
<Route
path="contracts/clearance"
element={

View File

@@ -99,7 +99,6 @@ export interface BookingContainerView {
containerType?: {
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}

View File

@@ -62,23 +62,22 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
const canGenerate = ["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(
status,
);
const canSign = ["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status);
// The contract is generated automatically when the final approval lands. We
// only surface a manual "Generate" fallback if that auto-generation failed —
// i.e. the contract is approved but no document was produced yet.
const needsManualGenerate =
["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(status) &&
!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);
const canCreateBooking =
status === "CLEARANCE_READY_FOR_BOOKING" &&
contract.customsClearingEnabled &&
canCreateContractBooking(user);
const signStaff = () =>
mutations.signContract.mutate({
role: "STAFF",
signatureImageBase64: "",
signerDisplayName:
user?.name?.en || user?.username || user?.email || "Staff",
});
return (
<SectionCard icon={Zap} title="Staff actions">
<Stack gap="sm">
@@ -117,27 +116,29 @@ export function ContractActionsToolbar({
</>
)}
{canGenerate && (
{needsManualGenerate && (
<Button
fullWidth
color="edr-green"
variant="light"
color="orange"
leftSection={<Sparkles size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Generate contract
Re-generate contract
</Button>
)}
{canSign && (
{canViewContract && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
loading={mutations.signContract.isPending}
onClick={signStaff}
onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}/view`)
}
>
Sign as staff
View &amp; sign contract
</Button>
)}
@@ -155,8 +156,8 @@ export function ContractActionsToolbar({
)}
{!canAccept &&
!canGenerate &&
!canSign &&
!needsManualGenerate &&
!canViewContract &&
!canCreateBooking && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the

View File

@@ -16,6 +16,47 @@ export interface ContractListRow {
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(
@@ -51,5 +92,7 @@ export function toContractListRow(contract: Freight.IContract): ContractListRow
validityDays: contract.contractValidityDays,
isRenewal: Boolean(contract.renewalOfId),
createdAt: contract.createdAt,
customsClearingEnabled: Boolean(contract.customsClearingEnabled),
contractGeneratedAt: contract.contractGeneratedAt,
};
}

View File

@@ -95,6 +95,11 @@ export function useContractMutations(contractId: string) {
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,
@@ -104,10 +109,30 @@ export function useContractMutations(contractId: string) {
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
onSuccess: (data) => onSuccess(data, "Approval step completed"),
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"),

View File

@@ -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: [

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Box,
Button,
Card,
Group,
Stack,
@@ -36,6 +37,7 @@ 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";
@@ -252,6 +254,29 @@ export default function ContractRequestsPage() {
);
},
},
{
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 (

View File

@@ -0,0 +1,176 @@
import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Group,
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);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
queryFn: () => contractsService.getContractView(id!),
enabled: Boolean(id),
});
const signMutation = useMutation({
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
signatureImageBase64: 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 confirmSign = () => {
if (!signerName.trim() || !signatureData) 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={() => {
setSignerName("");
setSignatureData(null);
setSignOpen(true);
}}
>
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)}
/>
<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={!signerName.trim() || !signatureData}
onClick={confirmSign}
>
Confirm signature
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

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

View File

@@ -79,7 +79,6 @@ export interface BookingContainerLine {
code?: string;
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}

View File

@@ -37,6 +37,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
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";
@@ -278,6 +279,10 @@ const App = () => {
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 />} />

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Box,
@@ -9,13 +9,11 @@ import {
Center,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
@@ -37,11 +35,7 @@ import {
Upload,
Weight,
} from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import {
BORDER,
@@ -64,10 +58,6 @@ const PATH_B_CLEARANCE = [
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [tab, setTab] = useState<string>("details");
const {
@@ -94,23 +84,6 @@ export default function ContractDetailPage() {
[bookingsPage, id],
);
const signMutation = useMutation({
mutationFn: (payload: {
role: "CUSTOMER";
signatureImageBase64: string;
signerDisplayName: string;
consentText?: string;
}) => contractsService.signContract(id!, payload),
onSuccess: () => {
toast.success("Contract signed successfully");
setSignOpen(false);
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }),
});
},
onError: () => toast.error("Failed to sign contract"),
});
if (isLoading) {
return (
<Center mih={400} p="xl">
@@ -154,16 +127,6 @@ export default function ContractDetailPage() {
const canUploadClearance =
customsPath && PATH_B_CLEARANCE.includes(contract.status);
const confirmSign = () => {
if (!signerName.trim() || !signatureData) return;
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: signatureData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
};
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
@@ -201,13 +164,9 @@ export default function ContractDetailPage() {
radius="md"
size="md"
leftSection={<FileSignature size={16} />}
onClick={() => {
setSignerName("");
setSignatureData(null);
setSignOpen(true);
}}
onClick={() => navigate(`/contracts/${contract.id}/view`)}
>
Sign contract
View &amp; sign contract
</Button>
)}
{canBookShipment && (
@@ -286,34 +245,48 @@ export default function ContractDetailPage() {
</SimpleGrid>
</Paper>
{/* Tabs: Details · Documents · Bookings */}
{/* Tabs: Details · Documents · Bookings (pill style, like the
backoffice booking-requests page; each tab shows a count badge). */}
<Tabs
value={tab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
keepMounted={false}
>
<Tabs.List mb="lg">
<Tabs.Tab value="details" leftSection={<FileText size={15} />}>
Details
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<Download size={15} />}>
Documents
</Tabs.Tab>
<Tabs.Tab value="bookings" leftSection={<Package size={15} />}>
Bookings
{contractBookings.length > 0 && (
<Badge
size="xs"
variant="light"
color="edr-green"
ml={8}
radius="sm"
>
{contractBookings.length}
</Badge>
)}
</Tabs.Tab>
<Tabs.List
mb="lg"
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
padding: 6,
background: "#F1F5F9",
borderRadius: 16,
border: "1px solid #E2E8F0",
width: "fit-content",
}}
>
<DetailTab
value="details"
active={tab === "details"}
icon={<FileText size={16} />}
label="Details"
/>
<DetailTab
value="documents"
active={tab === "documents"}
icon={<Download size={16} />}
label="Documents"
count={files.length}
/>
<DetailTab
value="bookings"
active={tab === "bookings"}
icon={<Package size={16} />}
label="Bookings"
count={contractBookings.length}
/>
</Tabs.List>
{/* ── Details tab ───────────────────────────────────────────── */}
@@ -755,52 +728,6 @@ export default function ContractDetailPage() {
</Tabs.Panel>
</Tabs>
</Stack>
{/* Sign modal */}
<Modal
opened={signOpen}
onClose={() => setSignOpen(false)}
title={<Text fw={700}>Sign contract</Text>}
radius="lg"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{contract.reference} your signature will be stored securely and
applied to the contract document.
</Text>
<TextInput
label="Full name"
placeholder="Your full name"
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
radius="md"
/>
<ContractSignaturePad onChange={setSignatureData} />
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setSignOpen(false)}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<FileSignature size={15} />}
disabled={
signMutation.isPending || !signatureData || !signerName.trim()
}
loading={signMutation.isPending}
onClick={confirmSign}
>
Confirm signature
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}
@@ -815,6 +742,62 @@ const KEY_FACT_ACCENT: Record<string, string> = {
orange: "#C77F09",
};
/**
* A pill-style detail tab matching the backoffice booking-requests tabs: an
* icon, a label, and an always-visible count badge (shows 0 when empty).
*/
function DetailTab({
value,
active,
icon,
label,
count,
}: {
value: string;
active: boolean;
icon: React.ReactNode;
label: string;
count?: number;
}) {
return (
<Tabs.Tab
value={value}
leftSection={icon}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={active ? "white" : "light"}
color={active ? "edr-green" : "gray"}
styles={
active
? {
root: {
background: "rgba(255,255,255,0.92)",
color: "#15805F",
},
}
: undefined
}
>
{count}
</Badge>
) : undefined
}
styles={{
tab: {
borderRadius: 11,
padding: "9px 16px",
fontWeight: 600,
},
}}
>
{label}
</Tabs.Tab>
);
}
/** A small uppercase eyebrow used as a section heading. */
function SectionLabel({
children,

View File

@@ -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 { api } from "@/services/api";
/**
* Customer contract preview + sign. Customers must open and read the generated
* contract here before signing — there is no sign action on the detail page or
* the contract list. Signing is only possible once the contract is generated
* and ready (CONTRACT_READY).
*/
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 saved signature for approval first; the customer can draw a fresh
// one instead.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["contract-view", id],
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: "CUSTOMER",
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData as string),
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
}),
onSuccess: () => {
toast.success("Contract signed successfully");
setSignOpen(false);
void refetch();
void qc.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }),
});
},
onError: () => toast.error("Failed to sign contract"),
});
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();
};
const handlePrint = () => iframeRef.current?.contentWindow?.print();
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(`/contracts/${id}`)}
>
Back to contract
</Button>
<Group gap="sm">
<Button
variant="default"
leftSection={<Printer size={16} />}
onClick={handlePrint}
>
Print
</Button>
{data.canSignCustomer && (
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</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={usingSaved ? "Approve signature" : "Sign contract"}
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>
);
}

View File

@@ -44,6 +44,39 @@ function primaryRoute(contract: Freight.IContract) {
};
}
const PATH_A_BOOKABLE_STATUSES = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
const PATH_B_CLEARANCE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
/** The single most relevant next action for a customer's contract row. */
function getCustomerRowAction(
contract: Freight.IContract,
): { label: string; to: string } {
const id = contract.id;
if (contract.status === "CONTRACT_READY") {
return { label: "View & sign", to: `/contracts/${id}/view` };
}
if (contract.status === "CHANGES_REQUESTED") {
return { label: "Edit & resubmit", to: `/contracts/${id}` };
}
if (
contract.customsClearingEnabled &&
PATH_B_CLEARANCE_STATUSES.includes(contract.status)
) {
return { label: "Upload clearance", to: `/contracts/${id}/clearance` };
}
if (
!contract.customsClearingEnabled &&
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
) {
return { label: "Book shipment", to: `/contracts/${id}/bookings/new` };
}
return { label: "View", to: `/contracts/${id}` };
}
export default function ContractsList() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -246,6 +279,27 @@ export default function ContractsList() {
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <ContractStatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <ColHeader label="Action" />,
cell: ({ row }) => {
const action = getCustomerRowAction(row.original);
return (
<Button
size="compact-sm"
radius="md"
variant={action.label === "View" ? "light" : "filled"}
color="edr-green"
onClick={(e) => {
e.stopPropagation();
navigate(action.to);
}}
>
{action.label}
</Button>
);
},
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";

View File

@@ -26,7 +26,7 @@ import {
Send,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
@@ -261,11 +261,19 @@ export default function NewContractPage() {
return active?.licenseFiles ?? [];
}, [auth.company, auth.activeCompanyProfileId]);
// The documents step validates required uploads imperatively (the requirement
// set is async-loaded), so it registers a validator we call before advancing.
const docsValidatorRef = useRef<(() => boolean) | null>(null);
async function handleContinue() {
const valid = await form.trigger(contractStepFields[step], {
shouldFocus: true,
});
if (!valid) return;
// Step 2 — Documents: every required document must be on file or uploaded.
if (step === 2 && docsValidatorRef.current && !docsValidatorRef.current()) {
return;
}
goToStep(1);
}
@@ -517,7 +525,9 @@ export default function NewContractPage() {
)}
{/* Step 2 — Documents. */}
{step === 2 && <StepDocuments form={form} />}
{step === 2 && (
<StepDocuments form={form} validatorRef={docsValidatorRef} />
)}
{/* Step 3 — Review & Submit. */}
{step === 3 && (

View File

@@ -1,7 +1,8 @@
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { Alert, Box, Group, Loader, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileText, FileUp } from "lucide-react";
import { AlertCircle, CheckCircle2, FileText, FileUp } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import { useEffect, useState, type MutableRefObject } from "react";
import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
@@ -32,15 +33,32 @@ function formatSize(bytes?: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Loosely normalise a label/key so "Investment License" ≈ "investment_license". */
function norm(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function hasUploaded(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/**
* Contract intake documents step (doc §7 step 5). Mirrors the booking documents
* step: the company's onboarding documents are shown read-only as a reference,
* and the customer may attach commercial / framework documents that are saved
* against the contract on submit. These are distinct from the post-sign
* clearance documents uploaded later on `/contracts/:id/clearance` (Path B).
* Contract intake documents step (doc §7 step 5). Documents default to what the
* company uploaded during onboarding; whatever onboarding does NOT already cover
* the customer must upload here. Every *required* document must be satisfied —
* either on file from the profile or freshly uploaded — before the wizard can
* advance. The page registers a validator the wizard calls on "Continue".
*/
export function StepDocuments({ form }: { form: ContractForm }) {
export function StepDocuments({
form,
validatorRef,
}: {
form: ContractForm;
validatorRef?: MutableRefObject<(() => boolean) | null>;
}) {
const auth = useAuth();
const [errors, setErrors] = useState<Record<string, string>>({});
const nationality = auth.company?.company?.nationality as
| string
@@ -60,18 +78,75 @@ export function StepDocuments({ form }: { form: ContractForm }) {
return active?.licenseFiles ?? [];
})();
// Normalised names of the documents already on file from onboarding, so a
// required field can be satisfied by a profile document instead of an upload.
const onFileKeys = new Set(
onboardingDocs
.map((d) => norm((d as { name?: string }).name ?? ""))
.filter(Boolean),
);
const documents = (form.watch("documents") ?? {}) as ContractDocuments;
const setDocuments = (next: Record<string, File | File[] | null>) => {
form.setValue("documents", next, { shouldDirty: true });
// Clear errors for fields that now have an upload.
setErrors((prev) => {
const updated = { ...prev };
for (const key of Object.keys(updated)) {
if (hasUploaded(next[key])) delete updated[key];
}
return updated;
});
};
const fields = docSettingQuery.data?.fields ?? [];
// A required field is satisfied if it's uploaded here OR already on file from
// onboarding (matched by fileKey or label). Returns the list of missing keys.
const missingRequired = (): string[] =>
fields
.filter((f) => f.isRequired)
.filter((f) => {
if (hasUploaded(documents[f.fileKey])) return false;
const onFile =
onFileKeys.has(norm(f.fileKey)) ||
onFileKeys.has(norm(f.fileLabel));
return !onFile;
})
.map((f) => f.fileKey);
// Register the validator the wizard calls before advancing past this step.
useEffect(() => {
if (!validatorRef) return;
validatorRef.current = () => {
const missing = missingRequired();
if (missing.length === 0) {
setErrors({});
return true;
}
const fieldsMap = new Map(fields.map((f) => [f.fileKey, f]));
const next: Record<string, string> = {};
for (const key of missing) {
next[key] = `${fieldsMap.get(key)?.fileLabel ?? "This document"} is required.`;
}
setErrors(next);
return false;
};
return () => {
if (validatorRef) validatorRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validatorRef, fields, documents, onboardingDocs]);
const hasErrors = Object.keys(errors).length > 0;
return (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Contract Documents"
description="Attach the framework / commercial documents for this contract. They default to what you uploaded during onboarding — upload here only to override a document for this contract."
description="Attach the documents required for this contract. Anything already on file from your onboarding is reused automatically — upload the rest here. Required documents must be provided to continue."
/>
{onboardingDocs.length > 0 && (
@@ -139,6 +214,23 @@ export function StepDocuments({ form }: { form: ContractForm }) {
Documents for this contract
</Text>
{hasErrors && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="md"
>
<Text size="sm" fw={600}>
Please provide all required documents
</Text>
<Text size="sm" mt={2} c="red.7">
Upload the documents marked in red below, or make sure they are on
file from your onboarding.
</Text>
</Alert>
)}
{docSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
@@ -148,6 +240,7 @@ export function StepDocuments({ form }: { form: ContractForm }) {
file={docSettingQuery.data}
value={documents}
onChange={setDocuments}
errors={errors}
/>
) : (
<Text size="sm" c="dimmed">