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>
151 lines
6.6 KiB
TypeScript
151 lines
6.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|