feat: implemented the changes request to the company profile to backoffice

This commit is contained in:
Nathnael
2026-07-08 08:07:10 +00:00
parent 2cae451edc
commit 3bc4514b04
9 changed files with 546 additions and 30 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
},
INVOICES: {

View File

@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`,
CHANGE_REQUESTS: (companyId: string) =>
`/companies/${companyId}/change-requests`,
CHANGE_REQUEST_APPROVE: (id: string) =>
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>

View File

@@ -32,6 +32,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -508,6 +510,7 @@ export default function CustomerDetailPage() {
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
/>
@@ -534,6 +537,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip
items={[
{

View File

@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
import type {
Company,
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -2261,13 +2262,13 @@ export const api = {
),
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus },
{ profileId: string; status: ProfileStatus; note?: string },
CompanyProfile
>(
"customers",
"setProfileStatus",
({ profileId, status }) =>
customersService.setProfileStatus(profileId, status),
({ profileId, status, note }) =>
customersService.setProfileStatus(profileId, status, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2275,6 +2276,37 @@ export const api = {
],
),
changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
"customers",
"changeRequests",
({ id }) => customersService.changeRequests(id),
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
),
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
"customers",
"approveChangeRequest",
({ id }) => customersService.approveChangeRequest(id),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
"customers",
"rejectChangeRequest",
({ id, note }) => customersService.rejectChangeRequest(id, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",

View File

@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -80,11 +81,15 @@ export const customersService = {
.then((r) => r.data);
},
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
setProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
): Promise<CompanyProfile> {
return apiClient
.patch<CompanyProfile>(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
{ status },
{ status, note },
)
.then((r) => r.data);
},
@@ -95,4 +100,32 @@ export const customersService = {
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
/** List a company's profile-edit change requests (newest first). */
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
return apiClient
.get<CompanyChangeRequest[]>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
)
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
)
.then((r) => r.data);
},
/** Reject a pending change request with a note. */
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
{ note },
)
.then((r) => r.data);
},
};

View File

@@ -30,7 +30,12 @@ export type ProfileType =
| "transporter";
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
export type ProfileStatus =
| "active"
| "pending"
| "rejected"
| "suspended"
| "blacklisted";
/** A business-license document uploaded for a company profile. */
export interface LicenseFile {
@@ -52,6 +57,29 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null;
/** Reviewer note when the role is rejected. */
reviewNote?: string | null;
createdAt: string;
updatedAt: string;
}
/** Lifecycle of a staged customer profile-edit review. */
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
/**
* A staged profile-edit change request. The customer's settings edits land here
* (pending) until a reviewer approves (applies them) or rejects (with a note).
*/
export interface CompanyChangeRequest {
id: string;
companyId: string;
status: ChangeRequestStatus;
/** Proposed field values (the diff payload vs. the live company). */
snapshot: Record<string, unknown>;
documentFileIds: string[];
note: string | null;
submittedAt: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}