mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
feat: implemented the changes request to the company profile to backoffice
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ClipboardCheck, Clock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
companyLocation: "Location",
|
||||
companyAddress: "Address",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
licenceNumber: "Licence number",
|
||||
contactPersonName: "Contact person",
|
||||
contactPersonPosition: "Contact position",
|
||||
contactPersonEmail: "Contact email",
|
||||
contactPersonPhone: "Contact phone",
|
||||
generalManagerName: "General manager",
|
||||
generalManagerEmail: "GM email",
|
||||
generalManagerPhone: "GM phone",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
companyName: c.name,
|
||||
companyEmail: c.email,
|
||||
companyPhone: c.phone,
|
||||
companyLocation: c.country,
|
||||
companyAddress: c.address,
|
||||
tin: c.tin,
|
||||
vatNumber: c.vatNumber,
|
||||
fanNumber: c.fanNumber,
|
||||
nationality: c.nationality,
|
||||
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
|
||||
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
|
||||
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
|
||||
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
|
||||
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
|
||||
};
|
||||
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
const changed = from !== to;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
{changed && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
const approve = useMutation(
|
||||
api.customers.approveChangeRequest.mutationOptions(),
|
||||
);
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
: ([] as string[]);
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{pending && (
|
||||
<Card withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ClipboardCheck size={18} className="text-edr-muted" />
|
||||
<Text fw={600} c="edr-text">
|
||||
Profile changes awaiting review
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="md">
|
||||
Pending
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
<DiffRow
|
||||
key={key}
|
||||
label={FIELD_LABELS[key] ?? humanize(key)}
|
||||
from={currentValue(company, key)}
|
||||
to={
|
||||
pending.snapshot[key] === null ||
|
||||
pending.snapshot[key] === undefined ||
|
||||
pending.snapshot[key] === ""
|
||||
? "—"
|
||||
: String(pending.snapshot[key])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{docCount > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
||||
request — review them in the Documents tab.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "N changes pending" pill for the customer list/detail header. */
|
||||
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
||||
);
|
||||
const pending = (query.data ?? []).some((r) => r.status === "pending");
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<Badge
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Clock size={12} />}
|
||||
>
|
||||
Changes pending review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import type {
|
||||
@@ -25,6 +35,7 @@ const badgeStyle = {
|
||||
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||
active: "edr-green",
|
||||
pending: "yellow",
|
||||
rejected: "red",
|
||||
suspended: "orange",
|
||||
blacklisted: "red",
|
||||
};
|
||||
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
|
||||
|
||||
/**
|
||||
* Inline approval action buttons for a profile row.
|
||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
||||
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||
* Rejecting captures a note the customer sees so they can fix and reapply.
|
||||
*/
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
const confirmReject = () => {
|
||||
mutate(
|
||||
{ profileId, status: "rejected", note: note.trim() },
|
||||
{ onSuccess: () => setRejectOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
const rejectModal = (
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject profile"
|
||||
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.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The uploaded business license is expired."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject profile
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{rejectModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,9 @@ export {
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
} from "./badges";
|
||||
export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
Reference in New Issue
Block a user