Initial commit: Converge portfolio rationalization app

Next.js app for mapping a customer's competitive tool landscape onto our
portfolio and building the consolidation business case: assessment
workbench, customer report and Word export, product catalog, competitive
knowledge base, dossier import, shareable landscape questionnaires with a
review inbox, tool-footprint and business-unit value analysis, and an
implementation timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:48:42 -04:00
commit d5c77543e8
64 changed files with 30861 additions and 0 deletions

150
src/app/page.tsx Normal file
View File

@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Assessment, AssessmentSummary } from "@/lib/types";
import { dateLabel, money } from "@/lib/format";
import { Empty } from "@/components/charts";
import LoadError from "@/components/LoadError";
import { errorMessage, getJson, sendJson } from "@/lib/client";
export default function Home() {
const router = useRouter();
const [rows, setRows] = useState<AssessmentSummary[] | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [loadError, setLoadError] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoadError("");
try {
setRows(await getJson<AssessmentSummary[]>("/api/assessments"));
} catch (e) {
setLoadError(errorMessage(e));
}
}, []);
useEffect(() => { load(); }, [load]);
/** Only navigate once the server has confirmed the new id. */
async function open(created: Assessment) {
if (!created?.id) throw new Error("The server did not return an assessment id.");
router.push(`/assessments/${created.id}`);
}
async function create(e: React.FormEvent) {
e.preventDefault();
if (!name.trim() || busy) return;
setBusy(true); setErr("");
try {
await open(await sendJson<Assessment>("/api/assessments", "POST", { customerName: name.trim() }));
} catch (e) {
setErr(errorMessage(e));
setBusy(false);
}
}
async function importFile(f: File) {
setBusy(true); setErr("");
try {
const parsed = JSON.parse(await f.text());
await open(await sendJson<Assessment>("/api/assessments", "POST", { import: parsed }));
} catch (e) {
setErr(f.name.endsWith(".json") ? errorMessage(e) : `${f.name} is not a JSON assessment file.`);
setBusy(false);
}
}
async function remove(id: string, label: string) {
if (!confirm(`Delete the assessment for ${label}? The JSON file is removed from data/assessments.`)) return;
try {
await sendJson(`/api/assessments/${id}`, "DELETE");
load();
} catch (e) {
setErr(errorMessage(e));
}
}
const totalSavings = (rows ?? []).reduce((s, r) => s + r.annualSavings, 0);
return (
<div className="fade-up">
<div className="mb-7 flex flex-wrap items-end gap-6">
<div>
<h1 className="text-[28px] font-semibold tracking-tight">Assessments</h1>
<p className="mt-1 max-w-2xl text-[13.5px] text-[var(--text-secondary)]">
Capture a customer&rsquo;s incumbent tool landscape, map it onto our portfolio, and build the consolidation
business case. Every assessment is a portable JSON file under <code className="rounded bg-[var(--surface-2)] px-1 py-0.5 text-[12px]">data/assessments/</code>.
</p>
</div>
{rows && rows.length > 0 && (
<div className="ml-auto text-right">
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Pipeline savings identified</div>
<div className="tabnum text-[22px] font-semibold" style={{ color: "var(--success-text)" }}>{money(totalSavings)}/yr</div>
</div>
)}
</div>
<div className="mb-6 flex flex-wrap items-end gap-3">
<form onSubmit={create} className="flex items-end gap-2">
<div className="w-[260px]">
<label className="label" htmlFor="new-customer">New assessment</label>
<input id="new-customer" className="field" placeholder="Customer name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<button className="btn btn-primary" disabled={!name.trim() || busy}>{busy ? "Working…" : "Create"}</button>
</form>
<button className="btn" onClick={() => fileRef.current?.click()} disabled={busy}>Import JSON</button>
<input
ref={fileRef} type="file" accept="application/json" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) importFile(f); e.target.value = ""; }}
/>
</div>
{err && (
<div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>
{err}
</div>
)}
{loadError ? (
<LoadError message={loadError} onRetry={load} />
) : rows === null ? (
<div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>
) : rows.length === 0 ? (
<Empty>No assessments yet — name a customer above to start one.</Empty>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((r) => (
<div key={r.id} className="card group relative p-5 transition-shadow hover:border-[var(--text-muted)]">
<Link href={`/assessments/${r.id}`} className="block">
<div className="flex items-start gap-3">
<h2 className="truncate text-[15.5px] font-semibold tracking-tight">{r.customerName}</h2>
<span className="chip ml-auto shrink-0">{r.candidateCount} tools</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend assessed</div>
<div className="tabnum text-[16px] font-semibold">{money(r.annualSpend, true)}</div>
</div>
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Annual saving</div>
<div className="tabnum text-[16px] font-semibold" style={{ color: "var(--success-text)" }}>{money(r.annualSavings, true)}</div>
</div>
</div>
<div className="mt-4 text-[11.5px] text-[var(--text-muted)]">
{r.preparedBy ? `${r.preparedBy} · ` : ""}Updated {dateLabel(r.updatedAt)}
</div>
</Link>
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Link className="btn btn-ghost btn-sm" href={`/assessments/${r.id}/report`}>Report</Link>
<button className="btn btn-ghost btn-sm btn-danger" onClick={() => remove(r.id, r.customerName)} aria-label={`Delete ${r.customerName}`}>×</button>
</div>
</div>
))}
</div>
)}
</div>
);
}