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 = { 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 ( No changes recorded yet. ); } return ( {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 ( {badge.label} {entry.summary && ( {entry.summary} )} {formatDate(entry.at)} {/* 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) && ( {requestedLine && ( {requestedLine} )} {decidedLine && ( {decidedLine} )} )} {entry.note && ( {entry.kind === "changes_requested" ? "What was asked for:" : "Note:"} {" "} {entry.note} )} {entry.fieldDiffs.length > 0 && ( {entry.fieldDiffs.map((f) => ( ))} )} {entry.docDiffs.length > 0 && ( {entry.docDiffs.map((d) => ( {d.label} {d.fromFile && ( openFile(d.fromFile!)} > {d.fromFile.name} )} {d.fromFile && d.toFile && ( )} {d.toFile && ( openFile(d.toFile!)} > {d.toFile.name} )} ))} )} {entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && ( No details recorded for this entry. )} ); })} ); }