mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge pull request #503 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -250,6 +250,44 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
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. */
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||||
async approveStep(
|
async approveStep(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto';
|
|||||||
import {
|
import {
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
RejectContractDto,
|
RejectContractDto,
|
||||||
|
RejectStepDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
} from './dto/approve-step.dto';
|
} from './dto/approve-step.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.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')
|
@Post(':id/contract/generate')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
||||||
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
import { Check, ShieldCheck, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Box,
|
Box,
|
||||||
Modal,
|
Modal,
|
||||||
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ export function ContractApprovalStepsCard({
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingStep, setPendingStep] =
|
const [pendingStep, setPendingStep] =
|
||||||
useState<Freight.IContractApprovalStep | null>(null);
|
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(
|
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 =
|
const subtitle =
|
||||||
summary.detail ||
|
summary.detail ||
|
||||||
(nextPending
|
(nextPending
|
||||||
@@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({
|
|||||||
key={step.id}
|
key={step.id}
|
||||||
step={step}
|
step={step}
|
||||||
isNext={nextPending?.id === step.id}
|
isNext={nextPending?.id === step.id}
|
||||||
isPending={mutations.approveStep.isPending}
|
isPending={
|
||||||
|
mutations.approveStep.isPending ||
|
||||||
|
mutations.rejectStep.isPending
|
||||||
|
}
|
||||||
onApprove={() => openApprove(step)}
|
onApprove={() => openApprove(step)}
|
||||||
|
onReject={() => openReject(step)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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,
|
isNext,
|
||||||
isPending,
|
isPending,
|
||||||
onApprove,
|
onApprove,
|
||||||
|
onReject,
|
||||||
}: {
|
}: {
|
||||||
step: Freight.IContractApprovalStep;
|
step: Freight.IContractApprovalStep;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
}) {
|
}) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
step.status === "APPROVED"
|
step.status === "APPROVED"
|
||||||
@@ -222,15 +303,27 @@ function StepRow({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
{isNext && step.status === "PENDING" && (
|
{isNext && step.status === "PENDING" && (
|
||||||
<Button
|
<>
|
||||||
size="compact-sm"
|
<Button
|
||||||
color="edr-green"
|
size="compact-sm"
|
||||||
leftSection={<Check size={14} />}
|
color="edr-green"
|
||||||
disabled={isPending}
|
leftSection={<Check size={14} />}
|
||||||
onClick={onApprove}
|
disabled={isPending}
|
||||||
>
|
onClick={onApprove}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onReject}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export const URL_CONSTANTS = {
|
|||||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||||
APPROVE_STEP: (id: string, stepId: string) =>
|
APPROVE_STEP: (id: string, stepId: string) =>
|
||||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
`/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_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: () => toast.error("Failed to approve step"),
|
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.
|
// Manual fallback generate — used only if auto-generation failed.
|
||||||
const generateContract = useMutation({
|
const generateContract = useMutation({
|
||||||
mutationFn: () => contractsService.generateContract(contractId),
|
mutationFn: () => contractsService.generateContract(contractId),
|
||||||
@@ -210,6 +219,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
requestChanges.isPending ||
|
requestChanges.isPending ||
|
||||||
reject.isPending ||
|
reject.isPending ||
|
||||||
approveStep.isPending ||
|
approveStep.isPending ||
|
||||||
|
rejectStep.isPending ||
|
||||||
generateContract.isPending ||
|
generateContract.isPending ||
|
||||||
signContract.isPending ||
|
signContract.isPending ||
|
||||||
createBooking.isPending;
|
createBooking.isPending;
|
||||||
@@ -219,6 +229,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
requestChanges,
|
requestChanges,
|
||||||
reject,
|
reject,
|
||||||
approveStep,
|
approveStep,
|
||||||
|
rejectStep,
|
||||||
generateContract,
|
generateContract,
|
||||||
signContract,
|
signContract,
|
||||||
createBooking,
|
createBooking,
|
||||||
|
|||||||
@@ -90,11 +90,16 @@ export default function ContractClearanceDetailPage() {
|
|||||||
].includes(contract.status),
|
].includes(contract.status),
|
||||||
);
|
);
|
||||||
const linkedBookingId = useMemo(() => {
|
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(
|
const cycle = contract?.clearanceCycles?.find(
|
||||||
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||||
);
|
);
|
||||||
return cycle?.bookingId ?? undefined;
|
return clearance?.linkedBookingId ?? cycle?.bookingId ?? undefined;
|
||||||
}, [contract]);
|
}, [clearance, contract]);
|
||||||
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||||
const canCreateBooking = ready && !bookingAlreadyCreated;
|
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||||
const reviewReadOnly = shipmentLocked;
|
const reviewReadOnly = shipmentLocked;
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ export const contractsService = {
|
|||||||
requiredRole,
|
requiredRole,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
rejectStep: ({
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
stepId: string;
|
||||||
|
reason: string;
|
||||||
|
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
|
||||||
|
|
||||||
// ── Contract document ──
|
// ── Contract document ──
|
||||||
generateContract: (id: string) =>
|
generateContract: (id: string) =>
|
||||||
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
||||||
|
|||||||
@@ -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);
|
const payable = findPayableBookingForContract(id, bookings);
|
||||||
if (payable) {
|
if (payable) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ import {
|
|||||||
MUTED,
|
MUTED,
|
||||||
} from "./contract-ui";
|
} 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
|
// Statuses where the customer uploads clearance documents on the contract. Used
|
||||||
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
|
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
|
||||||
// (non-customs IMPORT/EXPORT, Operations-reviewed).
|
// (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
|
// Not yet submitted (customer saved a draft) or staff returned the contract for
|
||||||
// wizard (edit any term + replace documents → resubmit) rather than the
|
// changes — send the customer to the full edit wizard (edit any term + replace
|
||||||
// read-only detail.
|
// documents → submit) rather than the read-only detail.
|
||||||
if (contract.status === "CHANGES_REQUESTED") {
|
if (
|
||||||
|
contract.status === "DRAFT" ||
|
||||||
|
contract.status === "RENEWAL_DRAFT" ||
|
||||||
|
contract.status === "CHANGES_REQUESTED"
|
||||||
|
) {
|
||||||
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
|
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +305,13 @@ export default function ContractDetailPage() {
|
|||||||
const clearanceFinalized =
|
const clearanceFinalized =
|
||||||
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
||||||
const canBookShipment =
|
|
||||||
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
|
||||||
const bookingAction = getContractBookingAction(contract, contractBookings);
|
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";
|
const canRequestShipment = bookingAction.kind === "request";
|
||||||
// Customs + clearance finalized: GL is preparing the booking — surface a
|
// Customs + clearance finalized: GL is preparing the booking — surface a
|
||||||
// status notice instead of any action.
|
// status notice instead of any action.
|
||||||
|
|||||||
@@ -81,9 +81,10 @@ type PriceModalMode = "submit" | "draft";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The contract wizard, used both to create a new contract and — in `edit` mode —
|
* 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
|
* to continue an unsubmitted DRAFT or edit & resubmit a contract staff returned
|
||||||
* hydrates the form from the saved contract, lets the customer change any term
|
* with CHANGES_REQUESTED. Edit mode hydrates the form from the saved contract,
|
||||||
* and replace documents, then runs the same update → price → submit flow.
|
* lets the customer change any term and replace documents, then runs the same
|
||||||
|
* update → price → submit flow.
|
||||||
*/
|
*/
|
||||||
export default function NewContractPage({
|
export default function NewContractPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
|
|||||||
Reference in New Issue
Block a user