mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Merge pull request #899 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||
// missing one, so staff can't send a link that will never arrive.
|
||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!target.phone}
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
target.phone ?? "No phone number on this account"
|
||||
!target.phone
|
||||
? "No phone number on this account"
|
||||
: target.phoneIsDomestic === false
|
||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||
: target.phone
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
// Queue ordering: awaiting first approval → pending profile changes → the
|
||||
// rest, newest first within each group. The default, so whatever marketing
|
||||
// must act on is always on top of the list.
|
||||
{ value: "review:DESC", label: "Needs review first" },
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||
@@ -93,11 +97,11 @@ export default function CustomersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
const [sort, setSort] = useState<string>("review:DESC");
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
"name" | "createdAt" | "updatedAt",
|
||||
"review" | "name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
|
||||
* already `active`, so `status` alone can never surface them.
|
||||
*/
|
||||
hasPendingChangeRequest?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
/** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user