mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
Three gaps on the backoffice customer detail page: - Rejecting a change request or sending it back for correction notified nobody. Adds CompanyNotifierService.changeRequestReturned, which pings the customer desk with the reviewer, the outcome and the note. Marketing joins that desk via customers:view + customers:get_notification in the role preset — grants still come from the IAM UI, the preset only sets the default for new environments. - submitted_by / reviewed_by / actor_id were stored but never resolved, so the History tab could say what changed but never who asked or who sent it back. Resolves them through a shared iam-user-name util (deduped from the private copy in contract-document-history.service) and renders "Requested by" / "Sent back to marketing by" lines. The changes_requested badge is relabelled to match the workflow. - "View" opened an in-page modal one document at a time. Adds openFileInNewTab, which opens the tab inside the click gesture and fills it once the authenticated fetch resolves, and an "Open all" button that loops over the documents table so every file lands in its own tab.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
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 { openFileInNewTab } 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;
|
|
/** Who filed the change (the customer, or staff editing during onboarding). */
|
|
requestedBy?: string | null;
|
|
/** When they filed it — the "asked" half of the ask/decide pair below. */
|
|
requestedAt?: string | null;
|
|
/** Who decided (approved / rejected / sent it back to marketing). */
|
|
decidedBy?: string | null;
|
|
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" },
|
|
// Sending a request back is what "reverted to marketing" means here: the
|
|
// request stays open and marketing owns the follow-up with the customer.
|
|
changes_requested: { label: "Sent back to marketing", color: "yellow" },
|
|
revision: { label: "Recorded", color: "blue" },
|
|
};
|
|
|
|
/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */
|
|
function actorLine(verb: string, who?: string | null, when?: string | null) {
|
|
if (!who && !when) return null;
|
|
return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
requestedBy: r.submittedByName,
|
|
requestedAt: r.submittedAt ?? r.createdAt,
|
|
decidedBy: r.reviewedByName,
|
|
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,
|
|
requestedBy: rev.actorName,
|
|
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 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 }) =>
|
|
openFileInNewTab(file.id, file.name);
|
|
|
|
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];
|
|
const requestedLine = actorLine(
|
|
entry.kind === "revision" ? "Edited" : "Requested",
|
|
entry.requestedBy,
|
|
entry.requestedAt,
|
|
);
|
|
const decidedLine = actorLine(
|
|
entry.kind === "changes_requested"
|
|
? "Sent back to marketing"
|
|
: entry.kind === "rejected"
|
|
? "Rejected"
|
|
: "Approved",
|
|
entry.decidedBy,
|
|
entry.kind === "revision" ? null : entry.at,
|
|
);
|
|
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>
|
|
|
|
{/* Who asked, and who decided. Without this the feed said what
|
|
changed and when, but never named a person — the first thing
|
|
anyone auditing a returned request needs. */}
|
|
{(requestedLine || decidedLine) && (
|
|
<Stack gap={2}>
|
|
{requestedLine && (
|
|
<Text size="xs" c="dimmed">
|
|
{requestedLine}
|
|
</Text>
|
|
)}
|
|
{decidedLine && (
|
|
<Text size="xs" c="dimmed">
|
|
{decidedLine}
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
{entry.note && (
|
|
<Alert color="yellow" variant="light">
|
|
<Text size="sm">
|
|
<strong>
|
|
{entry.kind === "changes_requested"
|
|
? "What was asked for:"
|
|
: "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>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
}
|