mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
import {
|
|
Alert,
|
|
Anchor,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
Modal,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
} from "@mantine/core";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
ClipboardCheck,
|
|
Clock,
|
|
FilePlus2,
|
|
FileX2,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useFileViewer } from "@edr/ui-common";
|
|
|
|
import { fileViewUrl } from "@/constants/apiConfig";
|
|
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 { view, viewer } = useFileViewer();
|
|
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 licenseChanges = pending?.licenseChanges ?? [];
|
|
|
|
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>
|
|
)}
|
|
|
|
{licenseChanges.length > 0 && (
|
|
<Stack gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
Business license changes
|
|
</Text>
|
|
{licenseChanges.map((c, i) => (
|
|
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
|
|
{c.op === "add" ? (
|
|
<FilePlus2 size={15} className="text-edr-muted" />
|
|
) : (
|
|
<FileX2 size={15} className="text-edr-muted" />
|
|
)}
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant="light"
|
|
color={c.op === "add" ? "green" : "red"}
|
|
>
|
|
{c.op === "add" ? "Add" : "Remove"}
|
|
</Badge>
|
|
<Anchor
|
|
component="button"
|
|
type="button"
|
|
size="sm"
|
|
onClick={() =>
|
|
view({
|
|
name: c.fileName ?? "License document",
|
|
url: fileViewUrl(c.fileId),
|
|
})
|
|
}
|
|
style={{
|
|
textDecoration:
|
|
c.op === "remove" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{c.fileName ?? "License document"}
|
|
</Anchor>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
<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>
|
|
|
|
{viewer}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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>
|
|
);
|
|
}
|