From f4d28e77fa6302ca3ee4146d0f6aca8333c3a489 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 7 Jul 2026 08:56:44 +0000 Subject: [PATCH] Implement reject step functionality for contract approval process and enhance contract editing experience --- .../contracts/contract-transition.service.ts | 38 +++++++++++++++++++ .../modules/contracts/contracts.controller.ts | 22 +++++++++++ .../deriveContractCustomerAction.ts | 12 ++++++ .../pages/contracts/ContractDetailPage.tsx | 24 +++++++----- .../src/pages/contracts/NewContractPage.tsx | 7 ++-- 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9cf06c905..9f12b937d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -250,6 +250,44 @@ export class ContractTransitionService { return updated; } + /** + * Reject one approval step (line staff / director / CEO). The rejecting + * approver must supply a reason. A rejection is terminal: the whole contract + * moves to REJECTED and the customer must create a new one — there is no + * resubmit of the same contract. The reason is recorded both on the step and + * as a REJECTION review note so it is visible to the customer and the rest of + * the approval chain. + */ + async rejectStep( + contractId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'REJECTION', + actorId, + 'STAFF', + ); + + await this.contractsRepository.update(contractId, { + status: 'REJECTED', + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8591a54d8..4ea7634b6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -60,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto'; import { ApproveStepDto, RejectContractDto, + RejectStepDto, RequestChangesDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -390,6 +391,27 @@ export class ContractsController { ); } + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff([ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, + ]) + @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.rejectStep( + id, + stepId, + resolveAuthUserId(user), + dto.reason, + ); + } + @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.contracts.generateContract) @ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' }) diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts index 4de7d2545..5d9a53c91 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts +++ b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts @@ -117,6 +117,18 @@ export function deriveContractCustomerAction( }; } + // Saved-but-not-submitted contract — send the customer back into the wizard to + // finish editing and submit it for review. + if (contract.status === "DRAFT" || contract.status === "RENEWAL_DRAFT") { + return { + type: "navigate", + label: "Continue draft", + to: `/contracts/${id}/edit`, + primary: true, + icon: PencilLine, + }; + } + const payable = findPayableBookingForContract(id, bookings); if (payable) { return { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 16225cd02..560d7b11e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -73,10 +73,6 @@ import { MUTED, } from "./contract-ui"; -// Statuses where a customer may create a shipment booking themselves. Reached -// only after self-clearance is approved by Operations (Path A) or, for DOMESTIC, -// directly at counter-sign. -const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"]; // Statuses where the customer uploads clearance documents on the contract. Used // by both paths: Path B (customs, GL-reviewed) and Path A self-clearance // (non-customs IMPORT/EXPORT, Operations-reviewed). @@ -263,10 +259,14 @@ export default function ContractDetailPage() { ); } - // Staff returned the contract for changes — send the customer to the full edit - // wizard (edit any term + replace documents → resubmit) rather than the - // read-only detail. - if (contract.status === "CHANGES_REQUESTED") { + // Not yet submitted (customer saved a draft) or staff returned the contract for + // changes — send the customer to the full edit wizard (edit any term + replace + // documents → submit) rather than the read-only detail. + if ( + contract.status === "DRAFT" || + contract.status === "RENEWAL_DRAFT" || + contract.status === "CHANGES_REQUESTED" + ) { return ; } @@ -305,9 +305,13 @@ export default function ContractDetailPage() { const clearanceFinalized = contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" || contract.status === "CLEARANCE_READY_FOR_BOOKING"; - const canBookShipment = - !customsPath && PATH_A_BOOKABLE.includes(contract.status); const bookingAction = getContractBookingAction(contract, contractBookings); + // Whether the customer may open a new self-service booking. Derived from the + // shared booking-action helper so it honours the ONE_TIME single-slot rule: + // once a non-terminal booking exists on a ONE_TIME contract there is no free + // slot, so the action is "none" and no booking button is shown. + const canBookShipment = + bookingAction.kind === "book" || bookingAction.kind === "rebook"; const canRequestShipment = bookingAction.kind === "request"; // Customs + clearance finalized: GL is preparing the booking — surface a // status notice instead of any action. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index c8021ed27..613d3dcc6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -81,9 +81,10 @@ type PriceModalMode = "submit" | "draft"; /** * The contract wizard, used both to create a new contract and — in `edit` mode — - * to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode - * hydrates the form from the saved contract, lets the customer change any term - * and replace documents, then runs the same update → price → submit flow. + * to continue an unsubmitted DRAFT or edit & resubmit a contract staff returned + * with CHANGES_REQUESTED. Edit mode hydrates the form from the saved contract, + * lets the customer change any term and replace documents, then runs the same + * update → price → submit flow. */ export default function NewContractPage({ mode = "create",