Merge pull request #503 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-07 12:05:49 +03:00
committed by GitHub
10 changed files with 224 additions and 26 deletions

View File

@@ -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<Contract> {
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,

View File

@@ -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' })

View File

@@ -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<Freight.IContractApprovalStep | null>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(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)}
/>
))}
</Stack>
@@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}
onClose={closeReject}
title="Reject this step?"
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
withAsterisk
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
@@ -158,11 +237,13 @@ function StepRow({
isNext,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
}) {
const statusColor =
step.status === "APPROVED"
@@ -222,15 +303,27 @@ function StepRow({
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={onApprove}
>
Approve
</Button>
<>
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={onApprove}
>
Approve
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<X size={14} />}
disabled={isPending}
onClick={onReject}
>
Reject
</Button>
</>
)}
<Badge
variant="light"

View File

@@ -162,6 +162,8 @@ export const URL_CONSTANTS = {
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/reject`,
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,

View File

@@ -181,6 +181,15 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to approve step"),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
@@ -210,6 +219,7 @@ export function useContractMutations(contractId: string) {
requestChanges.isPending ||
reject.isPending ||
approveStep.isPending ||
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
createBooking.isPending;
@@ -219,6 +229,7 @@ export function useContractMutations(contractId: string) {
requestChanges,
reject,
approveStep,
rejectStep,
generateContract,
signContract,
createBooking,

View File

@@ -90,11 +90,16 @@ export default function ContractClearanceDetailPage() {
].includes(contract.status),
);
const linkedBookingId = useMemo(() => {
// Prefer the clearance view's server-resolved linkedBookingId (same field the
// GL Djibouti page uses). The contract's clearanceCycles[cycle].bookingId can
// be null/stale for an export FCFS booking, which would disable
// useBookingMilestones → empty milestones → the export "Payment & wagon
// allocation" step reads FREIGHT_PAYMENT_SETTLED as not-done and stays stuck.
const cycle = contract?.clearanceCycles?.find(
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
);
return cycle?.bookingId ?? undefined;
}, [contract]);
return clearance?.linkedBookingId ?? cycle?.bookingId ?? undefined;
}, [clearance, contract]);
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
const canCreateBooking = ready && !bookingAlreadyCreated;
const reviewReadOnly = shipmentLocked;

View File

@@ -185,6 +185,16 @@ export const contractsService = {
requiredRole,
}),
rejectStep: ({
id,
stepId,
reason,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
// ── Contract document ──
generateContract: (id: string) =>
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),

View File

@@ -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 {

View File

@@ -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 <Navigate to={`/contracts/${contract.id}/edit`} replace />;
}
@@ -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.

View File

@@ -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",