diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 75027aefc..3efaa1e5e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { } /> + + + + } + /> - mutations.signContract.mutate({ - role: "STAFF", - signatureImageBase64: "", - signerDisplayName: - user?.name?.en || user?.username || user?.email || "Staff", - }); - return ( @@ -117,27 +116,29 @@ export function ContractActionsToolbar({ )} - {canGenerate && ( + {needsManualGenerate && ( )} - {canSign && ( + {canViewContract && ( )} @@ -155,8 +156,8 @@ export function ContractActionsToolbar({ )} {!canAccept && - !canGenerate && - !canSign && + !needsManualGenerate && + !canViewContract && !canCreateBooking && ( No staff actions available for this status. Monitor until the diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts b/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts index 961491147..69c394927 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts @@ -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, +): 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, }; } diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index ab1d6311f..af98d806a 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -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"), diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 0497f0ce8..4ecfeebd5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -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: [ diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 8c2e3bb23..70e9ac410 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -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: () => Action, + cell: ({ row }) => { + const action = getStaffRowAction(row.original); + if (!action) return null; + return ( + + ); + }, + }, ]; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx new file mode 100644 index 000000000..921706fae --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -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(null); + + const [signOpen, setSignOpen] = useState(false); + const [signerName, setSignerName] = useState(""); + const [signatureData, setSignatureData] = useState(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 ( + + + + ); + } + + if (isError || !data) { + return ( + + Could not load contract. + + + ); + } + + return ( + + + + + + + {data.canSignStaff && ( + + )} + + + + +