Files
Converge/src/app/page.tsx
Chris Olson d5c77543e8 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>
2026-09-24 11:48:42 -04:00

151 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}