feat(companies): require and deliver a staff message on suspend/reactivate

Staff could suspend or reactivate a customer role with one silent click:
no reason captured, nothing stored, and the customer was never told.
The API now rejects a suspend or reactivate without a non-empty note,
keeps the note in reviewNote while suspended, and sends the customer an
SMS/email/in-app notification quoting the staff message. In the
backoffice the reject-note modal is generalised into a decision modal
shared by reject, suspend and reactivate, so all three force a message.

EDRFREIGHT-188
This commit is contained in:
Nathnael
2026-07-21 09:09:15 +00:00
parent 4f6a559ae3
commit c8f932f5d5
3 changed files with 159 additions and 32 deletions

View File

@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const [rejectOpen, setRejectOpen] = useState(false);
const [decision, setDecision] = useState<
"reject" | "suspend" | "reactivate" | null
>(null);
const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
const confirmReject = () => {
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
// all capture a required message through the same modal; the API refuses
// suspend/reactivate without one.
const DECISIONS = {
reject: {
title: "Reject profile",
intro:
"Tell the customer what needs fixing. They'll see this note and can " +
"amend and resubmit the role for approval.",
label: "Reason for rejection",
placeholder: "e.g. The uploaded business license is expired.",
confirmLabel: "Reject profile",
color: "red",
status: "rejected" as ProfileStatus,
},
suspend: {
title: "Suspend role",
intro:
"Explain why this role is being suspended. The customer will see this " +
"message and cannot operate under the role until it is reactivated.",
label: "Reason for suspension",
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
confirmLabel: "Suspend role",
color: "orange",
status: "suspended" as ProfileStatus,
},
reactivate: {
title: "Reactivate role",
intro:
"Explain why this role is being reactivated. The customer will see " +
"this message and can operate under the role again.",
label: "Reactivation message",
placeholder: "e.g. Outstanding payments have been settled.",
confirmLabel: "Reactivate role",
color: "edr-green",
status: "active" as ProfileStatus,
},
} as const;
const openDecision = (kind: keyof typeof DECISIONS) => {
setNote("");
setDecision(kind);
};
const active = decision ? DECISIONS[decision] : null;
const confirmDecision = () => {
if (!active) return;
mutate(
{ profileId, status: "rejected", note: note.trim() },
{ onSuccess: () => setRejectOpen(false) },
{ profileId, status: active.status, note: note.trim() },
{ onSuccess: () => setDecision(null) },
);
};
const rejectModal = (
const decisionModal = active && (
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject profile"
opened
onClose={() => setDecision(null)}
title={active.title}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can
amend and resubmit the role for approval.
{active.intro}
</Text>
<Textarea
label="Reason for rejection"
placeholder="e.g. The uploaded business license is expired."
label={active.label}
placeholder={active.placeholder}
autosize
minRows={3}
value={note}
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectOpen(false)}
onClick={() => setDecision(null)}
disabled={isPending}
>
Cancel
</Button>
<Button
color="red"
color={active.color}
loading={isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
onClick={confirmDecision}
>
Reject profile
{active.confirmLabel}
</Button>
</Group>
</Stack>
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
if (status === "pending") {
return (
<>
{rejectModal}
{decisionModal}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
variant="light"
color="red"
radius="md"
onClick={() => setRejectOpen(true)}
onClick={() => openDecision("reject")}
>
Reject
</Button>
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
if (status === "active") {
return (
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => act("suspended")}
>
Suspend
</Button>
<>
{decisionModal}
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => openDecision("suspend")}
>
Suspend
</Button>
</>
);
}
if (status === "suspended") {
return (
<Group gap={6} wrap="nowrap">
{decisionModal}
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
onClick={() => openDecision("reactivate")}
>
Reactivate
</Button>