mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
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:
@@ -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={
|
||||
|
||||
@@ -99,7 +99,6 @@ export interface BookingContainerView {
|
||||
containerType?: {
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
isReefer?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface BookingContainerLine {
|
||||
code?: string;
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
isReefer?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 & 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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user