mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +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 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 { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +35,7 @@ const badgeStyle = {
|
|||||||
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||||
active: "edr-green",
|
active: "edr-green",
|
||||||
pending: "yellow",
|
pending: "yellow",
|
||||||
|
rejected: "red",
|
||||||
suspended: "orange",
|
suspended: "orange",
|
||||||
blacklisted: "red",
|
blacklisted: "red",
|
||||||
};
|
};
|
||||||
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline approval action buttons for a profile row.
|
* 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({
|
export function ProfileApprovalActions({
|
||||||
profileId,
|
profileId,
|
||||||
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
|
|||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
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") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<>
|
||||||
<Button
|
{rejectModal}
|
||||||
size="xs"
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="light"
|
<Button
|
||||||
color="edr-green"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="edr-green"
|
||||||
onClick={() => act("active")}
|
radius="md"
|
||||||
>
|
loading={isPending}
|
||||||
Approve
|
onClick={() => act("active")}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Approve
|
||||||
size="xs"
|
</Button>
|
||||||
variant="light"
|
<Button
|
||||||
color="red"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="red"
|
||||||
onClick={() => act("blacklisted")}
|
radius="md"
|
||||||
>
|
onClick={() => setRejectOpen(true)}
|
||||||
Reject
|
>
|
||||||
</Button>
|
Reject
|
||||||
</Group>
|
</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,
|
ProfileStatusBadge,
|
||||||
ProfileTypeBadge,
|
ProfileTypeBadge,
|
||||||
} from "./badges";
|
} from "./badges";
|
||||||
|
export {
|
||||||
|
ChangeRequestReview,
|
||||||
|
ChangeRequestPendingBadge,
|
||||||
|
} from "./ChangeRequestReview";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
|||||||
documents: (id: string) =>
|
documents: (id: string) =>
|
||||||
["customers", "detail", id, "documents"] as const,
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
|
changeRequests: (id: string) =>
|
||||||
|
["customers", "detail", id, "change-requests"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
INVOICES: {
|
INVOICES: {
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
|
|||||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||||
PROFILE_STATUS: (profileId: string) =>
|
PROFILE_STATUS: (profileId: string) =>
|
||||||
`/companies/company-profiles/${profileId}/status`,
|
`/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_CUSTOMER_VIEW: (id: string) =>
|
||||||
`/bookings/by-company/${id}/customer-view`,
|
`/bookings/by-company/${id}/customer-view`,
|
||||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
|
ChangeRequestPendingBadge,
|
||||||
|
ChangeRequestReview,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
@@ -508,6 +510,7 @@ export default function CustomerDetailPage() {
|
|||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
<CompanyStatusBadge status={company.status} />
|
<CompanyStatusBadge status={company.status} />
|
||||||
|
<ChangeRequestPendingBadge companyId={company.id} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -534,6 +537,8 @@ export default function CustomerDetailPage() {
|
|||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
<Tabs.Panel value="overview" pt="lg">
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<ChangeRequestReview company={company} />
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
|||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
@@ -2261,13 +2262,13 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
setProfileStatus: endpoint<
|
setProfileStatus: endpoint<
|
||||||
{ profileId: string; status: ProfileStatus },
|
{ profileId: string; status: ProfileStatus; note?: string },
|
||||||
CompanyProfile
|
CompanyProfile
|
||||||
>(
|
>(
|
||||||
"customers",
|
"customers",
|
||||||
"setProfileStatus",
|
"setProfileStatus",
|
||||||
({ profileId, status }) =>
|
({ profileId, status, note }) =>
|
||||||
customersService.setProfileStatus(profileId, status),
|
customersService.setProfileStatus(profileId, status, note),
|
||||||
undefined,
|
undefined,
|
||||||
(_input, data) => [
|
(_input, data) => [
|
||||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
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>(
|
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||||
"customers",
|
"customers",
|
||||||
"setCompanyStatus",
|
"setCompanyStatus",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
@@ -80,11 +81,15 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
|
setProfileStatus(
|
||||||
|
profileId: string,
|
||||||
|
status: ProfileStatus,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
return apiClient
|
return apiClient
|
||||||
.patch<CompanyProfile>(
|
.patch<CompanyProfile>(
|
||||||
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
||||||
{ status },
|
{ status, note },
|
||||||
)
|
)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
@@ -95,4 +100,32 @@ export const customersService = {
|
|||||||
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
||||||
.then((r) => r.data);
|
.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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ export type ProfileType =
|
|||||||
| "transporter";
|
| "transporter";
|
||||||
|
|
||||||
/** Mirrors backend `ProfileStatus`. */
|
/** 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. */
|
/** A business-license document uploaded for a company profile. */
|
||||||
export interface LicenseFile {
|
export interface LicenseFile {
|
||||||
@@ -52,6 +57,29 @@ export interface CompanyProfile {
|
|||||||
/** Business-license documents uploaded for this profile. */
|
/** Business-license documents uploaded for this profile. */
|
||||||
licenseFiles?: LicenseFile[];
|
licenseFiles?: LicenseFile[];
|
||||||
attributes?: Record<string, unknown> | null;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user