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/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 0d7adc5be..044c729ee 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck } from "lucide-react"; +import { Check, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -8,6 +8,7 @@ import { Button, Box, Modal, + Textarea, } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -30,6 +31,10 @@ export function ContractApprovalStepsCard({ const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectStepRow, setRejectStepRow] = + useState(null); + const [rejectReason, setRejectReason] = useState(""); const steps = useMemo( () => @@ -60,6 +65,28 @@ export function ContractApprovalStepsCard({ ); }; + const openReject = (step: Freight.IContractApprovalStep) => { + setRejectStepRow(step); + setRejectReason(""); + setRejectOpen(true); + }; + + const closeReject = () => { + setRejectOpen(false); + setRejectStepRow(null); + setRejectReason(""); + }; + + const trimmedReason = rejectReason.trim(); + + const runReject = () => { + if (!rejectStepRow || !trimmedReason) return; + mutations.rejectStep.mutate( + { stepId: rejectStepRow.id, reason: trimmedReason }, + { onSuccess: () => closeReject() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({ key={step.id} step={step} isNext={nextPending?.id === step.id} - isPending={mutations.approveStep.isPending} + isPending={ + mutations.approveStep.isPending || + mutations.rejectStep.isPending + } onApprove={() => openApprove(step)} + onReject={() => openReject(step)} /> ))} @@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({ + + + + + Rejecting the{" "} + + {rejectStepRow?.requiredRole} + {" "} + step rejects contract{" "} + + {contract.reference} + {" "} + outright. The customer must create a new contract — this cannot be + undone. + +