feat(portal): reviewer note and resubmit for rejected roles in settings

A customer whose role was rejected saw only a bare "Rejected" chip in
settings - the reviewer's note and the resubmit action existed solely
inside the contract wizard's block modal, so fixing and reapplying from
the profile page was impossible. The settings role card now shows the
reviewer note (for suspended roles too) and offers "Resubmit for
approval", which flips the role back to Pending and notifies the
backoffice through the existing roleReapplied inbox event.

RoleCard's locked variant now renders as a plain box instead of a
button so it can host the action button (buttons cannot nest) and the
new detail line.

EDRFREIGHT-233
This commit is contained in:
Nathnael
2026-07-21 09:09:04 +00:00
parent d76d198a18
commit 4f6a559ae3
2 changed files with 106 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react";
import {
Button,
Card,
@@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
},
});
// Resubmit a rejected role for review. Flips it back to Pending server-side
// and pings the backoffice, so the fix-and-resubmit loop can happen entirely
// from settings instead of only from the contract page's rejection banner.
const reapplyMutation = useMutation({
mutationFn: (profileId: string) =>
api.companies.reapplyProfile.call({ profileId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const handleSave = () => {
if (selected.size === 0) return;
mutation.mutate(Array.from(selected));
@@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
{options.map((opt) => {
const existing = profileByType.get(opt.type);
const view = existing ? roleStatusView(existing) : undefined;
const rejected = existing?.status === "rejected";
return (
<RoleCard
key={opt.type}
@@ -124,6 +141,28 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
approved={view?.approved}
lockedNote={view?.note}
lockedNoteColor={view?.color}
detail={
(rejected || existing?.status === "suspended") &&
existing?.reviewNote
? `Reviewer note: ${existing.reviewNote}`
: undefined
}
action={
rejected ? (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
) : undefined
}
onClick={() => toggle(opt.type)}
/>
);

View File

@@ -18,6 +18,14 @@ export interface RoleCardProps {
lockedNote?: string;
/** Mantine color for {@link lockedNote}; matches the role's status. */
lockedNoteColor?: string;
/** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */
detail?: string;
/**
* Interactive content (e.g. a resubmit button) rendered inside the card.
* Only honoured on a locked card — the interactive variant is itself a
* button, and buttons cannot nest.
*/
action?: React.ReactNode;
onClick?: () => void;
}
@@ -35,54 +43,70 @@ export default function RoleCard({
approved = false,
lockedNote,
lockedNoteColor = "edr-green",
detail,
action,
onClick,
}: RoleCardProps) {
const highlighted = selected || approved;
return (
<UnstyledButton
type="button"
onClick={locked ? undefined : onClick}
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card!"
} ${
locked
? "cursor-default"
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{label}
const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card!"
} ${
locked
? "cursor-default"
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
}`;
const content = (
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
{lockedNote}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
{lockedNote}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
{locked && detail && (
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{detail}
</Text>
)}
{locked && action && <Box mt="sm">{action}</Box>}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
);
// A locked card is display-only, so it renders as a plain box — which also
// lets `action` hold real buttons without nesting them inside a button.
if (locked) {
return <Box className={className}>{content}</Box>;
}
return (
<UnstyledButton type="button" onClick={onClick} className={className}>
{content}
</UnstyledButton>
);
}