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;
};
}