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:
150
src/app/page.tsx
Normal file
150
src/app/page.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user