mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval).
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import {
|
|
Alert,
|
|
Anchor,
|
|
Badge,
|
|
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 { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
import { fetchViewableFile } from "@/services/files.service";
|
|
import { api } from "@/services/api";
|
|
import type { Company } from "@/types/customer";
|
|
import { formatDate, humanize } from "./format";
|
|
|
|
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
|
export 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.",
|
|
statusDescription: "eTrade status",
|
|
dateRegistered: "Date registered",
|
|
renewedFrom: "Renewed from",
|
|
renewalDate: "Renewal date",
|
|
renewedTo: "Renewed to",
|
|
etradePhone: "eTrade phone",
|
|
ownerPassportNumber: "Owner passport number",
|
|
};
|
|
|
|
/** Best-effort current value on the live company for a proposed field key. */
|
|
export 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);
|
|
}
|
|
|
|
/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */
|
|
function faydaIdentitySubject(
|
|
snapshot: Record<string, unknown>,
|
|
): "owner" | "poa" | null {
|
|
if ("ownerFaydaSub" in snapshot) return "owner";
|
|
if ("poaFaydaSub" in snapshot) return "poa";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object
|
|
* (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic
|
|
* `DiffRow` loop below can't render it — it would just stringify to
|
|
* `[object Object]`. Render it as its own before/after block instead, using
|
|
* the company's current `identity.owner`/`identity.poa` as the "before" side.
|
|
*/
|
|
function FaydaIdentityDiff({
|
|
company,
|
|
snapshot,
|
|
}: {
|
|
company: Company;
|
|
snapshot: Record<string, unknown>;
|
|
}) {
|
|
const subject = faydaIdentitySubject(snapshot);
|
|
if (!subject) return null;
|
|
const current =
|
|
subject === "owner" ? company.identity?.owner : company.identity?.poa;
|
|
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
|
|
const verifiedAt = read("FaydaVerifiedAt");
|
|
const fields: { label: string; from?: string | null; to?: string }[] = [
|
|
{ label: "Name", from: current?.name, to: read("Name") },
|
|
{ label: "Email", from: current?.email, to: read("Email") },
|
|
{ label: "Phone", from: current?.phone, to: read("Phone") },
|
|
{ label: "Address", from: current?.address, to: read("Address") },
|
|
].filter((f) => f.to !== undefined);
|
|
|
|
return (
|
|
<Stack gap={8}>
|
|
<Group gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
|
|
</Text>
|
|
{verifiedAt && (
|
|
<Text size="xs" c="dimmed">
|
|
Verified {formatDate(verifiedAt)}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
{fields.length > 0 ? (
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
{fields.map((f) => (
|
|
<DiffRow
|
|
key={f.label}
|
|
label={f.label}
|
|
from={f.from?.trim() ? f.from : "—"}
|
|
to={f.to?.trim() ? f.to : "—"}
|
|
/>
|
|
))}
|
|
</SimpleGrid>
|
|
) : (
|
|
<Text size="sm" c="dimmed">
|
|
Identity re-verified — no name/email/phone/address change.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export 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 /
|
|
* Request changes actions. Past decisions live in the History tab's unified
|
|
* timeline (see {@link CompanyTimeline}), not here.
|
|
*/
|
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
|
const { user } = useAuth();
|
|
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
|
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 requestChanges = useMutation(
|
|
api.customers.requestChangeRequestChanges.mutationOptions(),
|
|
);
|
|
|
|
const { view, viewer } = useFileViewer();
|
|
const [actionTarget, setActionTarget] = useState<{
|
|
id: string;
|
|
kind: "reject" | "request-changes";
|
|
} | null>(null);
|
|
const [note, setNote] = useState("");
|
|
|
|
const requests = query.data ?? [];
|
|
const pending = requests.find((r) => r.status === "pending");
|
|
|
|
if (!pending) return null;
|
|
|
|
const proposedKeys = pending
|
|
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
|
: ([] as string[]);
|
|
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const docCount = pending?.documentFileIds?.length ?? 0;
|
|
const licenseChanges = pending?.licenseChanges ?? [];
|
|
const documentChanges = pending?.documentChanges ?? [];
|
|
|
|
const confirmAction = () => {
|
|
if (!actionTarget) return;
|
|
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
|
mutation.mutate(
|
|
{ id: actionTarget.id, note: note.trim() },
|
|
{
|
|
onSuccess: () => {
|
|
setActionTarget(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>
|
|
|
|
{pending.note && (
|
|
<Alert
|
|
color="yellow"
|
|
variant="light"
|
|
icon={<AlertTriangle size={16} />}
|
|
>
|
|
Changes were requested on an earlier round of this same
|
|
submission: <strong>{pending.note}</strong> — check whether
|
|
this resubmission actually addresses it before approving.
|
|
</Alert>
|
|
)}
|
|
|
|
{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>
|
|
) : !faydaIdentitySnapshot ? (
|
|
<Text size="sm" c="dimmed">
|
|
No field changes — document uploads only.
|
|
</Text>
|
|
) : null}
|
|
|
|
{faydaIdentitySnapshot && (
|
|
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
|
)}
|
|
|
|
{documentChanges.length > 0 && (
|
|
<Stack gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
Document changes
|
|
</Text>
|
|
{documentChanges.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={() =>
|
|
void fetchViewableFile(
|
|
c.fileId,
|
|
c.fileName ?? humanize(c.code),
|
|
).then(view)
|
|
}
|
|
style={{
|
|
textDecoration:
|
|
c.op === "remove" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{c.fileName ?? humanize(c.code)}
|
|
</Anchor>
|
|
<Text size="xs" c="dimmed">
|
|
{humanize(c.code)}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{docCount > 0 && (
|
|
<Stack gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
Documents uploaded with this request
|
|
</Text>
|
|
{pending!.documentFileIds.map((fileId, i) => (
|
|
<Group key={fileId} gap={8} wrap="nowrap">
|
|
<FilePlus2 size={15} className="text-edr-muted" />
|
|
<Anchor
|
|
component="button"
|
|
type="button"
|
|
size="sm"
|
|
onClick={() =>
|
|
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
|
|
view,
|
|
)
|
|
}
|
|
>
|
|
Document {i + 1}
|
|
</Anchor>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{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={() =>
|
|
void fetchViewableFile(
|
|
c.fileId,
|
|
c.fileName ?? "License document",
|
|
).then(view)
|
|
}
|
|
style={{
|
|
textDecoration:
|
|
c.op === "remove" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{c.fileName ?? "License document"}
|
|
</Anchor>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Reviewing the diff is `customers:view`; deciding on it is
|
|
`customers:verify`. Without it the request stays readable but
|
|
un-actionable. */}
|
|
{canReview && (
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="light"
|
|
color="red"
|
|
onClick={() => {
|
|
setActionTarget({ id: pending.id, kind: "reject" });
|
|
setNote("");
|
|
}}
|
|
>
|
|
Reject
|
|
</Button>
|
|
<Button
|
|
variant="light"
|
|
color="yellow"
|
|
onClick={() => {
|
|
setActionTarget({ id: pending.id, kind: "request-changes" });
|
|
setNote("");
|
|
}}
|
|
>
|
|
Request changes
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
loading={approve.isPending}
|
|
onClick={() => approve.mutate({ id: pending.id })}
|
|
>
|
|
Approve changes
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
<Modal
|
|
opened={actionTarget !== null}
|
|
onClose={() => setActionTarget(null)}
|
|
title={
|
|
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
|
}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Alert
|
|
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
|
variant="light"
|
|
icon={<AlertTriangle size={18} />}
|
|
>
|
|
{actionTarget?.kind === "reject"
|
|
? "The customer will see this note and can amend and resubmit."
|
|
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
|
</Alert>
|
|
<Textarea
|
|
label={
|
|
actionTarget?.kind === "reject"
|
|
? "Reason for rejection"
|
|
: "What needs to change"
|
|
}
|
|
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={() => setActionTarget(null)}
|
|
disabled={reject.isPending || requestChanges.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
|
loading={reject.isPending || requestChanges.isPending}
|
|
disabled={note.trim().length === 0}
|
|
onClick={confirmAction}
|
|
>
|
|
{actionTarget?.kind === "reject"
|
|
? "Reject changes"
|
|
: "Request 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>
|
|
);
|
|
}
|