doc compare

This commit is contained in:
2026-03-15 21:07:28 -05:00
parent f3e421e9e3
commit a43374fe77
24 changed files with 1142 additions and 55 deletions

View File

@@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
type RevisionOption = {
id: string;
label: string;
meta: string;
};
type ComparisonField = {
label: string;
value: string;
};
type ComparisonLine = {
key: string;
title: string;
subtitle?: string;
quantity: string;
unitLabel: string;
amountLabel: string;
totalLabel?: string;
extraLabel?: string;
};
type ComparisonDocument = {
title: string;
subtitle: string;
status: string;
metaFields: ComparisonField[];
totalFields: ComparisonField[];
notes: string;
lines: ComparisonLine[];
};
type DiffRow = {
key: string;
status: "ADDED" | "REMOVED" | "CHANGED";
left?: ComparisonLine;
right?: ComparisonLine;
};
function buildLineMap(lines: ComparisonLine[]) {
return new Map(lines.map((line) => [line.key, line]));
}
function lineSignature(line?: ComparisonLine) {
if (!line) {
return "";
}
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
}
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
const leftLines = buildLineMap(left.lines);
const rightLines = buildLineMap(right.lines);
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
const rows: DiffRow[] = [];
for (const key of orderedKeys) {
const leftLine = leftLines.get(key);
const rightLine = rightLines.get(key);
if (leftLine && !rightLine) {
rows.push({ key, status: "REMOVED", left: leftLine });
continue;
}
if (!leftLine && rightLine) {
rows.push({ key, status: "ADDED", right: rightLine });
continue;
}
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
}
}
return rows;
}
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
return left.flatMap((field) => {
const rightValue = rightByLabel.get(field.label);
if (rightValue == null || rightValue === field.value) {
return [];
}
return [
{
label: field.label,
leftValue: field.value,
rightValue,
},
];
});
}
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
return (
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
</div>
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
{document.status}
</span>
</div>
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
{document.metaFields.map((field) => (
<div key={`${label}-${field.label}`}>
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
<dd className="mt-1 text-sm text-text">{field.value}</dd>
</div>
))}
</dl>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{document.totalFields.map((field) => (
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
</div>
))}
</div>
<div className="mt-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
</div>
</article>
);
}
export function DocumentRevisionComparison({
title,
description,
currentLabel,
currentDocument,
revisions,
getRevisionDocument,
}: {
title: string;
description: string;
currentLabel: string;
currentDocument: ComparisonDocument;
revisions: RevisionOption[];
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
}) {
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
useEffect(() => {
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
}, [revisions]);
const leftDocument = getRevisionDocument(leftRevisionId);
const rightDocument = getRevisionDocument(rightRevisionId);
const diffRows = buildDiffRows(leftDocument, rightDocument);
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
return (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p>
<p className="mt-2 text-sm text-muted">{description}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block min-w-[220px]">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
<select
value={leftRevisionId}
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
>
{revisions.map((revision) => (
<option key={revision.id} value={revision.id}>
{revision.label} | {revision.meta}
</option>
))}
</select>
</label>
<label className="block min-w-[220px]">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
<select
value={rightRevisionId}
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
>
<option value="current">{currentLabel}</option>
{revisions.map((revision) => (
<option key={revision.id} value={revision.id}>
{revision.label} | {revision.meta}
</option>
))}
</select>
</label>
</div>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
<ComparisonCard label="Baseline" document={leftDocument} />
<ComparisonCard label="Compare To" document={rightDocument} />
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
{metaChanges.length === 0 && totalChanges.length === 0 ? (
<div className="mt-4 text-sm text-muted">No header or total changes between the selected revisions.</div>
) : (
<div className="mt-4 space-y-3">
{[...metaChanges, ...totalChanges].map((change) => (
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
<div className="mt-2 text-sm text-text">
{change.leftValue} {"->"} {change.rightValue}
</div>
</div>
))}
</div>
)}
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
</div>
</div>
{diffRows.length === 0 ? (
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div>
) : (
<div className="mt-4 space-y-3">
{diffRows.map((row) => (
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
</div>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
<div className="mt-1 text-sm text-text">
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
</div>
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
<div className="mt-1 text-sm text-text">
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
</div>
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
</div>
</div>
</div>
))}
</div>
)}
</article>
</div>
</section>
);
}