feat(freight): non-terminal change-request review + unified customer timeline

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).
This commit is contained in:
Nathnael
2026-07-31 14:12:30 +00:00
parent 3952e8bbdf
commit 4f81a0bbb8
23 changed files with 795 additions and 166 deletions

View File

@@ -2,7 +2,6 @@ import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Group,
@@ -27,11 +26,11 @@ 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, CompanyChangeRequest } from "@/types/customer";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
const FIELD_LABELS: Record<string, string> = {
export const FIELD_LABELS: Record<string, string> = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
};
/** Best-effort current value on the live company for a proposed field key. */
function currentValue(company: Company, key: string): string {
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> = {
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
);
}
function DiffRow({
export function DiffRow({
label,
from,
to,
@@ -201,8 +200,9 @@ function DiffRow({
/**
* 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.
* 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();
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const reject = useMutation(
api.customers.rejectChangeRequest.mutationOptions(),
);
const requestChanges = useMutation(
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [rejectId, setRejectId] = useState<string | null>(null);
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");
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
if (!pending && history.length === 0) return null;
if (!pending) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const licenseChanges = pending?.licenseChanges ?? [];
const documentChanges = pending?.documentChanges ?? [];
const confirmReject = () => {
if (!rejectId) return;
reject.mutate(
{ id: rejectId, note: note.trim() },
const confirmAction = () => {
if (!actionTarget) return;
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
mutation.mutate(
{ id: actionTarget.id, note: note.trim() },
{
onSuccess: () => {
setRejectId(null);
setActionTarget(null);
setNote("");
},
},
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</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) => (
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
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}
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</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"
opened={actionTarget !== null}
onClose={() => setActionTarget(null)}
title={
actionTarget?.kind === "reject" ? "Reject changes" : "Request 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
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="Reason for rejection"
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}
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectId(null)}
disabled={reject.isPending}
onClick={() => setActionTarget(null)}
disabled={reject.isPending || requestChanges.isPending}
>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
loading={reject.isPending || requestChanges.isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
onClick={confirmAction}
>
Reject changes
{actionTarget?.kind === "reject"
? "Reject changes"
: "Request changes"}
</Button>
</Group>
</Stack>

View File

@@ -1,53 +0,0 @@
import { Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { History } from "lucide-react";
import { api } from "@/services/api";
import { formatDate } from "./format";
/**
* Onboarding-phase edit history: what changed on the company record before it
* reached Active, the write path that has no approval gate (unlike edits made
* after approval, which go through {@link ChangeRequestReview} instead).
*/
export function CompanyRevisionHistory({ companyId }: { companyId: string }) {
const query = useQuery(
api.customers.revisions.queryOptions({ input: { id: companyId } }),
);
const revisions = query.data ?? [];
if (revisions.length === 0) return null;
return (
<Card withBorder>
<Stack gap="sm">
<Group gap="xs">
<History size={16} />
<Text fw={600} c="edr-text">
Version history
</Text>
</Group>
{revisions.map((rev) => (
<Box key={rev.id}>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Badge color="gray" variant="light" radius="md" tt="none">
{formatDate(rev.createdAt)}
</Badge>
<Text size="sm" c="edr-text" tt="capitalize">
{rev.summary}
</Text>
</Group>
{rev.changes.length > 0 && (
<Stack gap={2} ml={4} mt={4}>
{rev.changes.map((c, i) => (
<Text key={i} size="xs" c="dimmed">
{c.label}: {c.from ?? "—"} {c.to ?? "—"}
</Text>
))}
</Stack>
)}
</Box>
))}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,299 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
CompanyChangeRequest,
CompanyRevision,
CompanyRevisionChange,
DocumentChangeIntent,
LicenseChangeIntent,
} from "@/types/customer";
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
import { formatDate, humanize } from "./format";
interface DocDiff {
key: string;
label: string;
fromFile: { id: string; name: string } | null;
toFile: { id: string; name: string } | null;
}
interface FieldDiff {
key: string;
label: string;
from: string;
to: string;
}
interface TimelineEntry {
id: string;
kind: "approved" | "rejected" | "changes_requested" | "revision";
at: string;
note?: string | null;
summary?: string;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
* append after that pair, so adjacency survives. A remove or add with no
* adjacent partner stands alone.
*/
function pairIntents(
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
): DocDiff[] {
const diffs: DocDiff[] = [];
let i = 0;
while (i < intents.length) {
const current = intents[i];
const next = intents[i + 1];
if (current.op === "remove" && next?.op === "add") {
diffs.push({
key: `${current.fileId}-${next.fileId}`,
label: labelFor(next),
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
});
i += 2;
continue;
}
diffs.push({
key: `${current.fileId}-${i}`,
label: labelFor(current),
fromFile:
current.op === "remove"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
toFile:
current.op === "add"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
});
i += 1;
}
return diffs;
}
/**
* Historical field diffs on a change request only ever recorded the proposed
* ("to") value — there is no stored "before" snapshot — so `from` reads the
* CURRENT company value. That's exact for the most recent entry; for an older
* one it can drift if the field changed again since. A real limitation of the
* data model, not something this view can reconstruct.
*/
function fromChangeRequest(
r: CompanyChangeRequest,
company: Company,
): TimelineEntry {
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
(k) => k !== "faydaIdentity",
);
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
key,
label: FIELD_LABELS[key] ?? humanize(key),
from: currentValue(company, key),
to:
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
? "—"
: String(r.snapshot[key]),
}));
const docDiffs: DocDiff[] = [
...pairIntents(r.licenseChanges, () => "Business license"),
...pairIntents(r.documentChanges, (c) =>
humanize((c as DocumentChangeIntent).code),
),
...r.documentFileIds.map((fileId, i) => ({
key: fileId,
label: "Document",
fromFile: null,
toFile: { id: fileId, name: `Document ${i + 1}` },
})),
];
return {
id: r.id,
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
fieldDiffs,
docDiffs,
};
}
function fromRevision(rev: CompanyRevision): TimelineEntry {
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
const fieldDiffs: FieldDiff[] = rev.changes
.filter((c) => !isDocChange(c))
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
const docDiffs: DocDiff[] = rev.changes
.filter(isDocChange)
.map((c) => ({
key: c.field,
label: c.label,
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
}));
return {
id: rev.id,
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
fieldDiffs,
docDiffs,
};
}
/**
* One combined, chronological timeline of everything that's happened to a
* company's record: onboarding-phase edits (no approval gate, from
* `CompanyRevision`) and post-approval settings changes (reviewed via
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
* lists — merged here into one sorted feed so "what changed and when" has a
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
const revisionsQuery = useQuery(
api.customers.revisions.queryOptions({ input: { id: company.id } }),
);
const entries: TimelineEntry[] = [
...(changeRequestsQuery.data ?? [])
.filter((r) => r.status !== "pending")
.map((r) => fromChangeRequest(r, company)),
...(revisionsQuery.data ?? []).map(fromRevision),
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
if (entries.length === 0) {
return (
<Card withBorder>
<Stack align="center" gap={6} py="xl">
<History size={24} className="text-edr-muted" />
<Text size="sm" c="dimmed">
No changes recorded yet.
</Text>
</Stack>
</Card>
);
}
return (
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm">
<Badge color={badge.color} variant="light" radius="md">
{badge.label}
</Badge>
{entry.summary && (
<Text size="sm" c="edr-text" tt="capitalize">
{entry.summary}
</Text>
)}
</Group>
<Text size="xs" c="dimmed">
{formatDate(entry.at)}
</Text>
</Group>
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
</Text>
</Alert>
)}
{entry.fieldDiffs.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{entry.fieldDiffs.map((f) => (
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
))}
</SimpleGrid>
)}
{entry.docDiffs.length > 0 && (
<Stack gap={8}>
{entry.docDiffs.map((d) => (
<Group key={d.key} gap={8} wrap="nowrap">
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{d.label}
</Text>
{d.fromFile && (
<Group gap={4} wrap="nowrap">
<FileX2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
td="line-through"
onClick={() => openFile(d.fromFile!)}
>
{d.fromFile.name}
</Anchor>
</Group>
)}
{d.fromFile && d.toFile && (
<Text size="sm" c="edr-muted">
</Text>
)}
{d.toFile && (
<Group gap={4} wrap="nowrap">
<FilePlus2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
onClick={() => openFile(d.toFile!)}
>
{d.toFile.name}
</Anchor>
</Group>
)}
</Group>
))}
</Stack>
)}
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
<Text size="sm" c="dimmed">
No details recorded for this entry.
</Text>
)}
</Stack>
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -13,7 +13,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { CompanyRevisionHistory } from "./CompanyRevisionHistory";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,