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

View File

@@ -0,0 +1,187 @@
import { NextResponse } from "next/server";
import {
AlignmentType,
Document,
HeadingLevel,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from "docx";
import { getAssessment, getCatalog } from "@/lib/store";
import { customerSafe } from "@/lib/redact";
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
import { money, monthsLabel } from "@/lib/format";
const INK = "0B0B0B";
const MUTED = "52514E";
const ACCENT = "2A78D6";
function cell(text: string, opts: { bold?: boolean; fill?: string; align?: "left" | "right" } = {}) {
return new TableCell({
shading: opts.fill ? { type: ShadingType.CLEAR, color: "auto", fill: opts.fill } : undefined,
margins: { top: 80, bottom: 80, left: 120, right: 120 },
children: [
new Paragraph({
alignment: opts.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT,
children: [new TextRun({ text, bold: opts.bold, size: 19, color: opts.bold ? INK : MUTED })],
}),
],
});
}
function table(headers: string[], rows: string[][], aligns: ("left" | "right")[] = []) {
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
tableHeader: true,
children: headers.map((h, i) => cell(h, { bold: true, fill: "F0EFEC", align: aligns[i] })),
}),
...rows.map((r) => new TableRow({ children: r.map((v, i) => cell(v, { align: aligns[i] })) })),
],
});
}
const h1 = (t: string) =>
new Paragraph({ heading: HeadingLevel.HEADING_1, spacing: { before: 360, after: 160 }, children: [new TextRun({ text: t, bold: true, size: 30, color: INK })] });
const h2 = (t: string) =>
new Paragraph({ heading: HeadingLevel.HEADING_2, spacing: { before: 260, after: 120 }, children: [new TextRun({ text: t, bold: true, size: 24, color: INK })] });
const p = (t: string, color = MUTED) =>
new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: t, size: 21, color })] });
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const stored = await getAssessment(id);
if (!stored) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Same redaction as the on-screen report: the Word file is a customer artefact.
const a = customerSafe(stored);
const catalog = await getCatalog();
const r = rollup(a, catalog);
const prod = new Map(catalog.products.map((x) => [x.id, x]));
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
const kpis: string[][] = [
["Current annual spend assessed", money(r.addressableSpend)],
["Projected annual savings", money(r.totalAnnualSavings)],
[`${a.horizonYears}-year net benefit`, money(r.horizonNet)],
["One-time migration investment", money(r.totalMigrationCost)],
["Blended payback", monthsLabel(r.blendedPaybackMonths)],
["Tools consolidated", String(r.toolsRetired)],
["Vendors retired", String(r.vendorsRetired)],
["Spend reduction", `${r.savingsPct.toFixed(0)}%`],
];
const children = [
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: "SOFTWARE PORTFOLIO RATIONALIZATION", bold: true, size: 18, color: ACCENT })] }),
new Paragraph({ spacing: { after: 80 }, children: [new TextRun({ text: a.customerName || "Customer", bold: true, size: 48, color: INK })] }),
p(
[a.industry, a.preparedFor && `Prepared for ${a.preparedFor}`, a.preparedBy && `Prepared by ${a.preparedBy}`, a.opportunityRef]
.filter(Boolean)
.join(" • "),
),
p(new Date(a.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })),
h1("Executive summary"),
...(a.executiveSummary.trim()
? a.executiveSummary.split(/\n{2,}/).map((s) => p(s.trim(), INK))
: [
p(
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ${money(r.addressableSpend)} of assessed spend. Net of ${money(r.totalMigrationCost)} in migration investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ${monthsLabel(r.blendedPaybackMonths)}.`,
INK,
),
]),
h1("The financial case"),
table(["Measure", "Value"], kpis, ["left", "right"]),
h1("Rationalization roadmap"),
];
for (const w of [1, 2, 3] as const) {
const items = r.byWave[w];
if (!items.length) continue;
children.push(h2(WAVE_LABEL[w]));
children.push(p(WAVE_BLURB[w]));
children.push(
table(
["Incumbent tool", "Replaced by", "Annual spend", "Annual saving", "Payback"],
items.map((c) => {
const m = r.metrics.get(c.id)!;
return [
`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—",
c.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", ") || "—",
money(c.annualSpend),
money(m.annualSavings),
monthsLabel(m.paybackMonths),
];
}),
["left", "left", "right", "right", "right"],
),
);
}
children.push(h1("Consolidation detail by capability"));
children.push(
table(
["Capability domain", "Tools", "Current spend", "Annual saving"],
r.byDomain.map((d) => [d.name, String(d.count), money(d.spend), money(d.savings)]),
["left", "right", "right", "right"],
),
);
children.push(h1("Assessment detail"));
children.push(
table(
["Incumbent", "Domain", "Proposed platform", "Parity", "Switch ease", "Time to ROI", "Score"],
r.included.map((c) => {
const m = r.metrics.get(c.id)!;
return [
`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—",
domainName(c.domain),
c.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", ") || "—",
`${c.capability}/5`,
`${c.switchEase}/5`,
`${c.timeToRoiMonths} mo`,
m.score.toFixed(0),
];
}),
["left", "left", "left", "right", "right", "right", "right"],
),
);
const risky = r.included.filter((c) => c.risks.trim());
if (risky.length) {
children.push(h1("Risks and dependencies"));
for (const c of risky) {
children.push(h2(`${c.incumbentVendor} ${c.incumbentProduct}`.trim()));
children.push(p(c.risks));
}
}
children.push(h1("Method"));
children.push(
p(
`Each candidate is scored on functional parity, ease of switching and time to ROI, weighted ${a.weights.capability}/${a.weights.switchEase}/${a.weights.timeToRoi} with a ${a.weights.spend} weighting on spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across the ${a.horizonYears}-year horizon; migration cost is a one-time charge in year one. Figures are estimates for planning and are not a quotation.`,
),
);
const doc = new Document({
creator: a.preparedBy || "Converge",
title: `${a.customerName} — Software Portfolio Rationalization`,
styles: { default: { document: { run: { font: "Aptos", size: 21, color: MUTED } } } },
sections: [{ properties: { page: { margin: { top: 1000, bottom: 1000, left: 1000, right: 1000 } } }, children }],
});
const buf = await Packer.toBuffer(doc);
return new NextResponse(new Uint8Array(buf), {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${a.id}-rationalization.docx"`,
},
});
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getCatalog, getKnowledgeBase } from "@/lib/store";
import { buildImportResult } from "@/lib/dossier";
/**
* Parses a pasted dossier and returns what it found. Deliberately read-only:
* nothing is written until a seller accepts specific proposals in the UI.
*/
export async function POST(req: Request) {
const { text, sourceUrl } = (await req.json()) as { text?: string; sourceUrl?: string };
if (!text || !text.trim()) {
return NextResponse.json({ error: "Paste the dossier text first." }, { status: 400 });
}
if (text.length > 2_000_000) {
return NextResponse.json({ error: "That document is too large to parse." }, { status: 413 });
}
const [kb, catalog] = await Promise.all([getKnowledgeBase(), getCatalog()]);
return NextResponse.json(buildImportResult(text, (sourceUrl ?? "").trim(), kb, catalog));
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getAssessment } from "@/lib/store";
import { customerSafe } from "@/lib/redact";
/**
* The customer-facing projection of an assessment.
*
* The report page reads this instead of the full record so internal-only
* material never crosses the wire, rather than merely going unrendered.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const a = await getAssessment(id);
return a ? NextResponse.json(customerSafe(a)) : NextResponse.json({ error: "Not found" }, { status: 404 });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { deleteAssessment, getAssessment, saveAssessment } from "@/lib/store";
import type { Assessment } from "@/lib/types";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params;
const a = await getAssessment(id);
return a ? NextResponse.json(a) : NextResponse.json({ error: "Not found" }, { status: 404 });
}
export async function PUT(req: Request, { params }: Ctx) {
const { id } = await params;
const body = (await req.json()) as Assessment;
return NextResponse.json(await saveAssessment({ ...body, id }));
}
export async function DELETE(_req: Request, { params }: Ctx) {
const { id } = await params;
await deleteAssessment(id);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { deleteSurvey, getSurvey, mutateSurvey } from "@/lib/store";
type Ctx = { params: Promise<{ id: string; token: string }> };
type Body =
| { action: "reopen" }
| { action: "review"; decisions: Record<string, "accepted" | "dismissed"> }
| { action: "rename"; respondentName: string };
/** Seller-side changes. Answers are the respondent's and are never written here. */
export async function PUT(req: Request, { params }: Ctx) {
const { id, token } = await params;
const body = (await req.json()) as Body;
const result = await mutateSurvey(token, (s) => {
if (s.assessmentId !== id) return "Not found";
if (body.action === "reopen") {
s.status = "open";
} else if (body.action === "review") {
for (const [k, v] of Object.entries(body.decisions ?? {})) {
if (v === "accepted" || v === "dismissed") s.review[k] = v;
}
} else if (body.action === "rename") {
s.respondentName = String(body.respondentName ?? "").trim().slice(0, 120);
} else {
return "Unknown action";
}
});
if (result === null || result === "Not found") return NextResponse.json({ error: "Not found" }, { status: 404 });
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 400 });
return NextResponse.json(result);
}
export async function DELETE(_req: Request, { params }: Ctx) {
const { id, token } = await params;
const s = await getSurvey(token);
if (!s || s.assessmentId !== id) return NextResponse.json({ error: "Not found" }, { status: 404 });
await deleteSurvey(token);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { createSurvey, getAssessment, getCatalog, listSurveys } from "@/lib/store";
import { surveyableDomains } from "@/lib/survey";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params;
if (!(await getAssessment(id))) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(await listSurveys(id));
}
export async function POST(req: Request, { params }: Ctx) {
const { id } = await params;
if (!(await getAssessment(id))) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = (await req.json()) as { respondentName?: string; domains?: string[] };
// Keep catalog order and drop anything that is not a live category.
const wanted = new Set(Array.isArray(body.domains) ? body.domains : []);
const domains = surveyableDomains(await getCatalog()).map((d) => d.id).filter((d) => wanted.has(d));
if (!domains.length) return NextResponse.json({ error: "Pick at least one category." }, { status: 400 });
const name = typeof body.respondentName === "string" ? body.respondentName.trim().slice(0, 120) : "";
return NextResponse.json(await createSurvey(id, name, domains));
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { createAssessment, listAssessments, saveAssessment } from "@/lib/store";
import type { Assessment } from "@/lib/types";
export async function GET() {
return NextResponse.json(await listAssessments());
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
// An "import" payload carries a full candidate list; anything else is a fresh assessment.
if (body?.import) {
const src = body.import as Assessment;
const created = await createAssessment({ ...src, candidates: src.candidates ?? [] });
return NextResponse.json(created, { status: 201 });
}
return NextResponse.json(await createAssessment(body), { status: 201 });
}
export async function PUT(req: Request) {
const a = (await req.json()) as Assessment;
if (!a?.id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
return NextResponse.json(await saveAssessment(a));
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getCatalog, saveCatalog } from "@/lib/store";
import type { Catalog } from "@/lib/types";
export async function GET() {
return NextResponse.json(await getCatalog());
}
export async function PUT(req: Request) {
const body = (await req.json()) as Catalog;
if (!Array.isArray(body?.products) || !Array.isArray(body?.businessUnits)) {
return NextResponse.json({ error: "Malformed catalog" }, { status: 400 });
}
const ids = new Set<string>();
for (const p of body.products) {
if (!p.id || !p.name) return NextResponse.json({ error: "Product needs an id and a name" }, { status: 400 });
if (ids.has(p.id)) return NextResponse.json({ error: `Duplicate product id: ${p.id}` }, { status: 400 });
ids.add(p.id);
}
return NextResponse.json(await saveCatalog(body));
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getKnowledgeBase, saveKnowledgeBase } from "@/lib/store";
import type { KnowledgeBase } from "@/lib/types";
export async function GET() {
return NextResponse.json(await getKnowledgeBase());
}
export async function PUT(req: Request) {
const body = (await req.json()) as KnowledgeBase;
if (!Array.isArray(body?.entries)) {
return NextResponse.json({ error: "Malformed knowledge base" }, { status: 400 });
}
const ids = new Set<string>();
for (const e of body.entries) {
if (!e.id || !e.label) return NextResponse.json({ error: "Every entry needs an id and a label" }, { status: 400 });
if (ids.has(e.id)) return NextResponse.json({ error: `Duplicate entry id: ${e.id}` }, { status: 400 });
ids.add(e.id);
}
return NextResponse.json(await saveKnowledgeBase(body));
}

View File

@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { getAssessment, getCatalog, getKnowledgeBase, getSurvey, mutateSurvey } from "@/lib/store";
import { domainOptions, sanitizeAnswers } from "@/lib/survey";
import type { PublicSurvey, Survey } from "@/lib/types";
/**
* The respondent's endpoint. Anyone holding the link reaches it, so it returns
* only the category list and this questionnaire's own answers — never the
* assessment's numbers, the dossier, or other questionnaires.
*/
type Ctx = { params: Promise<{ token: string }> };
const notFound = () => NextResponse.json({ error: "This questionnaire link is not valid." }, { status: 404 });
async function toPublic(s: Survey): Promise<PublicSurvey | null> {
const [a, catalog, kb] = await Promise.all([getAssessment(s.assessmentId), getCatalog(), getKnowledgeBase()]);
if (!a) return null;
const byId = new Map(catalog.domains.map((d) => [d.id, d]));
return {
id: s.id,
customerName: a.customerName,
respondentName: s.respondentName,
status: s.status,
answers: s.answers,
categories: s.domains
.filter((d) => byId.has(d))
.map((d) => ({
id: d,
name: byId.get(d)!.name,
description: byId.get(d)!.description ?? "",
options: domainOptions(kb, d),
})),
};
}
export async function GET(_req: Request, { params }: Ctx) {
const { token } = await params;
const s = await getSurvey(token);
const pub = s && (await toPublic(s));
return pub ? NextResponse.json(pub) : notFound();
}
/** Autosave. Body: { answers, respondentName? } */
export async function PUT(req: Request, { params }: Ctx) {
const { token } = await params;
const body = (await req.json().catch(() => null)) as { answers?: unknown; respondentName?: unknown } | null;
if (!body) return NextResponse.json({ error: "Malformed request" }, { status: 400 });
const result = await mutateSurvey(token, (s) => {
if (s.status !== "open") return "This questionnaire has already been submitted.";
s.answers = sanitizeAnswers(s.domains, body.answers);
if (typeof body.respondentName === "string") s.respondentName = body.respondentName.trim().slice(0, 120);
});
if (result === null) return notFound();
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 409 });
return NextResponse.json({ ok: true, updatedAt: result.updatedAt });
}
/** Submit. Saves the final answers and locks the questionnaire. */
export async function POST(req: Request, { params }: Ctx) {
const { token } = await params;
const body = (await req.json().catch(() => null)) as { answers?: unknown; respondentName?: unknown } | null;
const result = await mutateSurvey(token, (s) => {
if (s.status !== "open") return "This questionnaire has already been submitted.";
if (body?.answers) s.answers = sanitizeAnswers(s.domains, body.answers);
if (typeof body?.respondentName === "string") s.respondentName = body.respondentName.trim().slice(0, 120);
s.status = "submitted";
s.submittedAt = new Date().toISOString();
});
if (result === null) return notFound();
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 409 });
const pub = await toPublic(result);
return pub ? NextResponse.json(pub) : notFound();
}

View File

@@ -0,0 +1,435 @@
"use client";
import Link from "next/link";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Assessment, Candidate, Catalog, IntelImportResult, IntelProposal, KnowledgeBase, Survey, Weights } from "@/lib/types";
import { DEFAULT_WEIGHTS, WAVE_LABEL, rollup } from "@/lib/scoring";
import { dateLabel, money, monthsLabel } from "@/lib/format";
import { ChartFrame, Empty, Quadrant, StatTile } from "@/components/charts";
import CandidateRow from "@/components/CandidateRow";
import IntelImport from "@/components/IntelImport";
import AccountIntelStrip from "@/components/AccountIntelStrip";
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
import LoadError from "@/components/LoadError";
import SurveyPanel from "@/components/SurveyPanel";
import SurveyReview, { kbEntryFor, toolVendorProduct } from "@/components/SurveyReview";
import { type PendingTool, candidateKey, pendingTools, toolFacts } from "@/lib/survey";
import { HttpError, errorMessage, getJson, sendJson } from "@/lib/client";
const newCandidate = (domain = ""): Candidate => ({
id: `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
incumbentVendor: "", incumbentProduct: "", domain, productIds: [],
annualSpend: 0, units: 0, unitLabel: "", renewalDate: "",
capability: 4, switchEase: 3, timeToRoiMonths: 12,
savingsPct: 60, migrationCost: 0,
strategicValue: 3, risks: "", notes: "", included: true,
});
export default function Workbench({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [a, setA] = useState<Assessment | null>(null);
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [kb, setKb] = useState<KnowledgeBase | null>(null);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [showMeta, setShowMeta] = useState(false);
const [showImport, setShowImport] = useState(false);
const [surveys, setSurveys] = useState<Survey[]>([]);
const [showSurveys, setShowSurveys] = useState(false);
const [reviewing, setReviewing] = useState<Survey | null>(null);
const [notFound, setNotFound] = useState(false);
const [loadError, setLoadError] = useState("");
const [attempt, setAttempt] = useState(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
setLoadError(""); setNotFound(false);
// The knowledge base only powers autocomplete, so a failure there must not
// block the workbench: it resolves to null and the picker simply goes quiet.
Promise.all([
getJson<Assessment>(`/api/assessments/${id}`),
getJson<Catalog>("/api/catalog"),
getJson<KnowledgeBase>("/api/knowledge-base").catch(() => null),
])
.then(([as, cat, base]) => { if (!cancelled) { setA(as); setCatalog(cat); setKb(base); } })
.catch((err) => {
if (cancelled) return;
// A missing assessment is a different story from a failed request.
if (err instanceof HttpError && err.status === 404) setNotFound(true);
else setLoadError(errorMessage(err));
});
return () => { cancelled = true; };
}, [id, attempt]);
// Questionnaires live in their own files, so a failure here never blocks the workbench.
const loadSurveys = useCallback(() => {
getJson<Survey[]>(`/api/assessments/${id}/surveys`).then(setSurveys).catch(() => {});
}, [id]);
// Respondents submit from elsewhere, so check again whenever the seller comes back to the tab.
useEffect(() => {
loadSurveys();
window.addEventListener("focus", loadSurveys);
return () => window.removeEventListener("focus", loadSurveys);
}, [loadSurveys]);
// Debounced autosave: the workbench is a live editing surface, not a form.
const save = useCallback((next: Assessment) => {
if (timer.current) clearTimeout(timer.current);
setStatus("saving");
timer.current = setTimeout(async () => {
try {
await sendJson(`/api/assessments/${next.id}`, "PUT", next);
setStatus("saved");
} catch {
setStatus("error");
}
}, 500);
}, []);
const update = useCallback((fn: (draft: Assessment) => void) => {
setA((prev) => {
if (!prev) return prev;
const next = structuredClone(prev);
fn(next);
save(next);
return next;
});
}, [save]);
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
/** Accepted proposals become real candidates; the dossier itself stays internal. */
function acceptIntel(result: IntelImportResult, chosen: IntelProposal[]) {
update((d) => {
d.accountIntel = result.intel;
if (!d.industry && result.intel.industry) d.industry = result.intel.industry;
const existing = new Set(
d.candidates.map((c) => `${c.incumbentVendor} ${c.incumbentProduct}`.trim().toLowerCase()),
);
for (const p of chosen) {
const label = `${p.vendor} ${p.product}`.trim().toLowerCase();
if (existing.has(label)) continue;
existing.add(label);
d.candidates.unshift({
...newCandidate(p.domain),
incumbentVendor: p.vendor,
incumbentProduct: p.product,
productIds: [...p.productIds],
capability: p.capability,
switchEase: p.switchEase,
timeToRoiMonths: p.timeToRoiMonths,
savingsPct: p.savingsPct,
migrationCost: p.migrationCost,
risks: p.risks,
notes: `From account dossier — ${p.evidence}`,
});
}
});
setShowImport(false);
}
/**
* Accepted answers become candidates seeded from the knowledge base; a tool
* already on the assessment only has its blank spend and renewal filled in.
*/
async function acceptSurvey(sv: Survey, accepted: PendingTool[], dismissed: PendingTool[]) {
const who = sv.respondentName || "questionnaire respondent";
update((d) => {
const byLabel = new Map(d.candidates.map((c) => [candidateKey(c.incumbentVendor, c.incumbentProduct), c]));
for (const t of accepted) {
const { vendor, product } = toolVendorProduct(kb, t);
const facts = toolFacts(t);
const hit = byLabel.get(candidateKey(vendor, product));
if (hit) {
if (!hit.annualSpend && facts.annualSpend) hit.annualSpend = facts.annualSpend;
if (!hit.renewalDate && facts.renewalDate) hit.renewalDate = facts.renewalDate;
continue;
}
const e = kbEntryFor(kb, t);
const c: Candidate = {
...newCandidate(t.domain),
incumbentVendor: vendor,
incumbentProduct: product,
...facts,
...(e ? {
productIds: [...e.productIds], unitLabel: e.unitLabel,
capability: e.capability, switchEase: e.switchEase, timeToRoiMonths: e.timeToRoiMonths,
savingsPct: e.savingsPct, migrationCost: e.migrationCost, risks: e.risks,
} : {}),
notes: `From questionnaire — ${who}, ${dateLabel(sv.submittedAt)}`,
};
d.candidates.unshift(c);
byLabel.set(candidateKey(vendor, product), c);
}
});
setReviewing(null);
const decisions = Object.fromEntries([
...accepted.map((t) => [t.key, "accepted"]),
...dismissed.map((t) => [t.key, "dismissed"]),
]);
try {
await sendJson(`/api/assessments/${id}/surveys/${sv.id}`, "PUT", { action: "review", decisions });
} catch {
// The tools are already on the assessment; the inbox just offers them again,
// and a second accept only fills blanks on the lines it finds.
} finally {
loadSurveys();
}
}
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
if (notFound) {
return (
<div className="card p-10 text-center">
<p className="text-[14px] font-medium">No assessment with the id <code>{id}</code>.</p>
<Link href="/" className="btn mt-4">Back to assessments</Link>
</div>
);
}
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>;
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
function exportJson() {
const url = URL.createObjectURL(new Blob([JSON.stringify(a, null, 2)], { type: "application/json" }));
const el = document.createElement("a");
el.href = url; el.download = `${a!.id}.json`; el.click();
URL.revokeObjectURL(url);
}
function exportCsv() {
const head = [
"incumbent_vendor", "incumbent_product", "domain", "replacement_products", "annual_spend_usd",
"quantity", "unit", "renewal_date", "capability_parity_1_5", "switch_ease_1_5", "time_to_roi_months",
"strategic_value_1_5", "savings_pct", "migration_cost_usd", "annual_saving_usd",
`net_benefit_${a!.horizonYears}yr_usd`, "payback_months", "score", "wave", "included", "risks", "notes",
];
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = a!.candidates.map((c) => {
const m = r!.metrics.get(c.id)!;
return [
c.incumbentVendor, c.incumbentProduct, domainName(c.domain),
c.productIds.map((pid) => catalog!.products.find((p) => p.id === pid)?.name ?? pid).join("; "),
c.annualSpend, c.units, c.unitLabel, c.renewalDate, c.capability, c.switchEase, c.timeToRoiMonths,
c.strategicValue, c.savingsPct, c.migrationCost, Math.round(m.annualSavings), Math.round(m.horizonNet),
m.paybackMonths === null ? "" : m.paybackMonths.toFixed(1), m.score.toFixed(0), m.wave,
c.included, c.risks, c.notes,
].map(esc).join(",");
});
const url = URL.createObjectURL(new Blob([[head.join(","), ...lines].join("\n")], { type: "text/csv" }));
const el = document.createElement("a");
el.href = url; el.download = `${a!.id}-rationalization.csv`; el.click();
URL.revokeObjectURL(url);
}
const statusText = { idle: "", saving: "Saving…", saved: "Saved", error: "Save failed" }[status];
return (
<div className="fade-up">
{/* ---- header ---- */}
<div className="mb-5 flex flex-wrap items-start gap-4">
<div className="min-w-0">
<Link href="/" className="text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">← Assessments</Link>
<input
className="-ml-1 mt-1 block w-full max-w-[520px] rounded-lg border border-transparent bg-transparent px-1 text-[28px] font-semibold tracking-tight outline-none hover:border-[var(--border)] focus:border-[var(--accent)]"
value={a.customerName} aria-label="Customer name"
onChange={(e) => update((d) => { d.customerName = e.target.value; })} />
<button className="mt-1 text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]" onClick={() => setShowMeta((v) => !v)}>
{[a.industry, a.preparedFor, a.preparedBy, a.opportunityRef].filter(Boolean).join(" · ") || "Add engagement details"} {showMeta ? "▴" : "▾"}
</button>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
<span className="tabnum min-w-[54px] text-[12px]" style={{ color: status === "error" ? "var(--critical)" : "var(--text-muted)" }}>{statusText}</span>
<button className="btn" onClick={() => setShowSurveys(true)}>Questionnaires{surveys.length ? ` · ${surveys.length}` : ""}</button>
<button className="btn" onClick={() => setShowImport(true)}>Import dossier</button>
<button className="btn" onClick={exportCsv}>CSV</button>
<button className="btn" onClick={exportJson}>JSON</button>
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Word</a>
<Link className="btn btn-primary" href={`/assessments/${a.id}/report`}>Customer report →</Link>
</div>
</div>
{showMeta && (
<div className="card fade-up mb-5 grid gap-3 p-4 sm:grid-cols-2 lg:grid-cols-4">
{([
["industry", "Industry"], ["preparedFor", "Prepared for"],
["preparedBy", "Prepared by"], ["opportunityRef", "Opportunity reference"],
] as const).map(([k, label]) => (
<div key={k}>
<label className="label" htmlFor={`m-${k}`}>{label}</label>
<input id={`m-${k}`} className="field" value={a[k]} onChange={(e) => update((d) => { d[k] = e.target.value; })} />
</div>
))}
<div className="sm:col-span-2 lg:col-span-4">
<label className="label" htmlFor="m-summary">Executive summary (leave blank to auto-generate in the report)</label>
<textarea id="m-summary" className="field" rows={3} value={a.executiveSummary}
onChange={(e) => update((d) => { d.executiveSummary = e.target.value; })} />
</div>
</div>
)}
{a.accountIntel && (
<AccountIntelStrip
intel={a.accountIntel}
onClear={() => update((d) => { d.accountIntel = null; })}
/>
)}
{surveys.filter((s) => pendingTools(s).length > 0).map((s) => (
<div key={s.id} className="card fade-up mb-4 flex flex-wrap items-center gap-3 px-4 py-3" style={{ borderColor: "var(--accent)" }}>
<span className="h-2 w-2 rounded-full bg-[var(--accent)]" />
<span className="text-[13px]">
<strong>{s.respondentName || "A respondent"}</strong> submitted the landscape questionnaire —{" "}
{pendingTools(s).length} tool{pendingTools(s).length === 1 ? "" : "s"} to review.
</span>
<button className="btn btn-primary btn-sm ml-auto" onClick={() => setReviewing(s)}>Review</button>
</div>
))}
{showSurveys && (
<SurveyPanel assessmentId={a.id} catalog={catalog} surveys={surveys} onChanged={loadSurveys}
onClose={() => setShowSurveys(false)}
onReview={(s) => { setShowSurveys(false); setReviewing(s); }} />
)}
{reviewing && (
<SurveyReview survey={reviewing} catalog={catalog} kb={kb} candidates={a.candidates}
onClose={() => setReviewing(null)}
onAccept={(acc, dis) => acceptSurvey(reviewing, acc, dis)} />
)}
{showImport && (
<IntelImport catalog={catalog} onClose={() => setShowImport(false)} onAccept={acceptIntel} />
)}
{/* ---- KPIs ---- */}
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`${r.toolsRetired} tools · ${r.vendorsRetired} vendors`} />
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% of assessed spend`} />
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`Average score ${r.avgScore.toFixed(0)}/100`} />
<ToolFootprintTile r={r} />
</div>
<div className="mb-5 grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<WeightsPanel a={a} update={update} />
<ChartFrame
title="Where to start"
subtitle="Capability parity against ease of switching. Top-right moves first."
table={{
headers: ["Incumbent", "Parity", "Switch ease", "Spend", "Score"],
rows: r.included.map((c) => {
const m = r.metrics.get(c.id)!;
return [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(c.annualSpend, true), m.score.toFixed(0)];
}),
}}
>
<Quadrant
groups={[{ label: WAVE_LABEL[1], colorIndex: 0 }, { label: WAVE_LABEL[2], colorIndex: 1 }, { label: WAVE_LABEL[3], colorIndex: 2 }]}
points={r.included.map((c) => {
const m = r.metrics.get(c.id)!;
return {
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(c.annualSpend, 1),
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
sublabel: c.productIds.map((pid) => catalog.products.find((p) => p.id === pid)?.name ?? pid).join(", ") || "No replacement mapped",
colorIndex: m.wave - 1, groupLabel: WAVE_LABEL[m.wave],
};
})}
/>
</ChartFrame>
</div>
<div className="mb-5">
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
</div>
<ImplementationTimeline a={a} r={r} catalog={catalog} onParallel={(n) => update((d) => { d.planParallel = n; })} />
{/* ---- candidates ---- */}
<div className="mb-3 flex flex-wrap items-center gap-3">
<h2 className="text-[17px] font-semibold tracking-tight">Competitive landscape</h2>
<span className="chip">{a.candidates.length} tools</span>
<div className="ml-auto flex gap-2">
<button className="btn" onClick={() => update((d) => {
const seen = new Set(d.candidates.map((c) => c.id));
const n = newCandidate();
while (seen.has(n.id)) n.id += "x";
d.candidates.unshift(n);
})}>+ Add incumbent tool</button>
</div>
</div>
{a.candidates.length === 0 ? (
<Empty>Add the customer&rsquo;s incumbent tools to begin. Type a vendor and product and the tool suggests replacements from our portfolio.</Empty>
) : (
<div className="flex flex-col gap-2.5">
{a.candidates.map((c) => (
<CandidateRow
key={c.id} c={c} m={r.metrics.get(c.id)!} catalog={catalog} kb={kb}
onChange={(next) => update((d) => { const i = d.candidates.findIndex((x) => x.id === c.id); if (i >= 0) d.candidates[i] = next; })}
onDelete={() => update((d) => { d.candidates = d.candidates.filter((x) => x.id !== c.id); })}
onDuplicate={() => update((d) => {
const i = d.candidates.findIndex((x) => x.id === c.id);
d.candidates.splice(i + 1, 0, { ...structuredClone(c), id: newCandidate().id });
})}
/>
))}
</div>
)}
</div>
);
}
function WeightsPanel({ a, update }: { a: Assessment; update: (fn: (d: Assessment) => void) => void }) {
const rows: [keyof Weights, string, string][] = [
["capability", "Capability parity", "How completely we replace the incumbent"],
["switchEase", "Ease of switching", "How little friction the migration carries"],
["timeToRoi", "Time to ROI", "How quickly the customer sees payback"],
["spend", "Spend at stake", "How much annual cost the line represents"],
];
const total = rows.reduce((s, [k]) => s + a.weights[k], 0);
return (
<section className="card p-5">
<div className="mb-1 flex items-baseline gap-2">
<h3 className="text-[14px] font-semibold tracking-tight">Scoring weights</h3>
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => update((d) => { d.weights = { ...DEFAULT_WEIGHTS }; })}>Reset</button>
</div>
<p className="mb-4 text-[12.5px] text-[var(--text-muted)]">
Tune how the rationalization score is composed. Weights are normalised, so only their ratio matters.
</p>
<div className="space-y-4">
{rows.map(([k, label, hint]) => (
<div key={k}>
<div className="mb-1 flex items-baseline gap-2">
<label className="label mb-0" htmlFor={`w-${k}`}>{label}</label>
<span className="ml-auto tabnum text-[12.5px] font-semibold">{total ? Math.round((a.weights[k] / total) * 100) : 0}%</span>
</div>
<input id={`w-${k}`} type="range" min={0} max={50} value={a.weights[k]}
onChange={(e) => update((d) => { d.weights[k] = Number(e.target.value); })} />
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>
</div>
))}
</div>
<div className="mt-5 border-t pt-4">
<label className="label" htmlFor="horizon">Planning horizon</label>
<div className="flex gap-1.5">
{[1, 3, 5].map((y) => (
<button key={y} id={y === 1 ? "horizon" : undefined}
className={`btn btn-sm flex-1 justify-center ${a.horizonYears === y ? "btn-primary" : ""}`}
onClick={() => update((d) => { d.horizonYears = y; })}>{y} year{y > 1 ? "s" : ""}</button>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,284 @@
"use client";
import Link from "next/link";
import { use, useEffect, useMemo, useState } from "react";
import type { Assessment, Catalog } from "@/lib/types";
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
import { dateLabel, money, monthsLabel } from "@/lib/format";
import { ChartFrame, Empty, HBar, PaybackCurve, Quadrant, StatTile } from "@/components/charts";
import LoadError from "@/components/LoadError";
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
import { errorMessage, getJson } from "@/lib/client";
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
export default function Report({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [a, setA] = useState<Assessment | null>(null);
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [loadError, setLoadError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setLoadError("");
// Deliberately the redacted projection, not the full record.
Promise.all([getJson<Assessment>(`/api/assessments/${id}/report`), getJson<Catalog>("/api/catalog")])
.then(([as, cat]) => { if (!cancelled) { setA(as); setCatalog(cat); } })
.catch((err) => { if (!cancelled) setLoadError(errorMessage(err)); });
return () => { cancelled = true; };
}, [id, attempt]);
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading report…</div>;
const prodName = (pid: string) => catalog.products.find((p) => p.id === pid)?.name ?? pid;
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
const autoSummary =
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ` +
`${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ` +
`${money(r.addressableSpend)} of assessed spend, and reduces the estate from ${r.toolsRetired} tools to ${r.toolsAfter}. Net of ${money(r.totalMigrationCost)} in one-time migration ` +
`investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ` +
`${monthsLabel(r.blendedPaybackMonths)}.`;
return (
<article className="mx-auto max-w-[1180px]">
{/* ---- toolbar (screen only) ---- */}
<div className="no-print mb-6 flex flex-wrap items-center gap-2">
<Link href={`/assessments/${a.id}`} className="text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">← Back to workbench</Link>
<div className="ml-auto flex gap-2">
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Download Word</a>
<button className="btn btn-primary" onClick={() => window.print()}>Print / Save as PDF</button>
</div>
</div>
{/* ---- cover ---- */}
<header className="avoid-break mb-8 border-b pb-7">
<div className="text-[11.5px] font-semibold uppercase tracking-[.1em]" style={{ color: "var(--accent)" }}>
Software portfolio rationalization
</div>
<h1 className="mt-2 text-[40px] font-semibold leading-[1.1] tracking-tight">{a.customerName}</h1>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-[var(--text-secondary)]">
{a.industry && <span>{a.industry}</span>}
{a.preparedFor && <span>Prepared for {a.preparedFor}</span>}
{a.preparedBy && <span>Prepared by {a.preparedBy}</span>}
{a.opportunityRef && <span>Ref {a.opportunityRef}</span>}
<span className="text-[var(--text-muted)]">{dateLabel(a.updatedAt)}</span>
</div>
</header>
{r.included.length === 0 ? (
<Empty>No tools are included in this assessment yet. Add candidates in the workbench.</Empty>
) : (
<>
{/* ---- executive summary ---- */}
<section className="avoid-break mb-7">
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Executive summary</h2>
<div className="max-w-[72ch] space-y-3 text-[14.5px] leading-[1.65] text-[var(--text-secondary)]">
{(a.executiveSummary.trim() || autoSummary).split(/\n{2,}/).map((p, i) => <p key={i}>{p}</p>)}
</div>
</section>
{/* ---- headline numbers ---- */}
<section className="avoid-break mb-7 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`Across ${r.toolsRetired} tools`} />
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% run-rate reduction`} />
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`${r.vendorsRetired} vendor relationships retired`} />
<ToolFootprintTile r={r} />
</section>
{/* ---- value over time ---- */}
<section className="mb-7 grid gap-4 lg:grid-cols-2">
<ChartFrame
title="Cumulative net benefit"
subtitle={`Savings held flat across ${a.horizonYears} years, net of one-time migration cost.`}
table={{
headers: ["Point", "Net benefit"],
rows: Array.from({ length: a.horizonYears + 1 }, (_, i) => [
i === 0 ? "Start" : `Year ${i}`, money(r.totalAnnualSavings * i - r.totalMigrationCost),
]),
}}
>
<PaybackCurve annualSavings={r.totalAnnualSavings} migrationCost={r.totalMigrationCost} years={a.horizonYears} />
</ChartFrame>
<ChartFrame
title="Annual saving by capability domain"
subtitle="Where the consolidation value concentrates."
table={{
headers: ["Domain", "Tools", "Current spend", "Annual saving"],
rows: r.byDomain.map((d) => [d.name, d.count, money(d.spend), money(d.savings)]),
}}
>
<HBar data={r.byDomain.slice(0, 9).map((d) => ({
label: d.name, value: d.savings, note: `${d.count} tool${d.count > 1 ? "s" : ""} · ${money(d.spend)} current spend`,
}))} />
</ChartFrame>
</section>
{/* ---- roadmap ---- */}
<section className="page-break mb-7">
<h2 className="mb-1 text-[19px] font-semibold tracking-tight">Rationalization roadmap</h2>
<p className="mb-4 max-w-[72ch] text-[13.5px] text-[var(--text-secondary)]">
Candidates are sequenced by a weighted score of capability parity, switching effort, speed to payback and
spend at stake. Wave 1 carries the least risk and the fastest return.
</p>
<div className="grid gap-4 lg:grid-cols-3">
{([1, 2, 3] as const).map((w) => {
const items = r.byWave[w];
const savings = items.reduce((s, c) => s + r.metrics.get(c.id)!.annualSavings, 0);
return (
<div key={w} className="card avoid-break overflow-hidden">
<div className="border-b px-4 py-3" style={{ borderTop: `3px solid ${WAVE_COLOR[w - 1]}` }}>
<h3 className="text-[14px] font-semibold tracking-tight">{WAVE_LABEL[w]}</h3>
<p className="mt-1 text-[12px] leading-snug text-[var(--text-muted)]">{WAVE_BLURB[w]}</p>
<div className="mt-2.5 flex items-baseline gap-2">
<span className="tabnum text-[19px] font-semibold" style={{ color: "var(--success-text)" }}>{money(savings, true)}</span>
<span className="text-[12px] text-[var(--text-muted)]">per year · {items.length} tool{items.length === 1 ? "" : "s"}</span>
</div>
</div>
{items.length === 0 ? (
<p className="px-4 py-5 text-center text-[12.5px] text-[var(--text-muted)]">Nothing in this wave.</p>
) : (
<ul className="divide-y">
{items.map((c) => {
const m = r.metrics.get(c.id)!;
return (
<li key={c.id} className="px-4 py-3">
<div className="text-[13px] font-medium">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled"}</div>
<div className="mt-0.5 text-[12px] text-[var(--text-secondary)]">
→ {c.productIds.map(prodName).join(", ") || "Not yet mapped"}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 tabnum text-[11.5px] text-[var(--text-muted)]">
<span>{money(m.annualSavings)}/yr saved</span>
<span>{monthsLabel(m.paybackMonths)} payback</span>
{c.renewalDate && <span>Renews {dateLabel(c.renewalDate)}</span>}
</div>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</section>
{/* ---- implementation plan ---- */}
<ImplementationTimeline a={a} r={r} catalog={catalog} />
{/* ---- quadrant ---- */}
<section className="mb-7">
<ChartFrame
title="Replacement readiness"
subtitle="Capability parity against ease of switching, sized by annual spend at stake."
table={{
headers: ["Incumbent", "Parity", "Switch ease", "Spend at stake"],
rows: r.included.map((c) => [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(c.annualSpend)]),
}}
>
<Quadrant
groups={[{ label: "Wave 1", colorIndex: 0 }, { label: "Wave 2", colorIndex: 1 }, { label: "Wave 3", colorIndex: 2 }]}
points={r.included.map((c) => {
const m = r.metrics.get(c.id)!;
return {
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(c.annualSpend, 1),
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
sublabel: c.productIds.map(prodName).join(", ") || "Not yet mapped",
colorIndex: m.wave - 1, groupLabel: WAVE_LABEL[m.wave],
};
})}
/>
</ChartFrame>
</section>
{/* ---- value by business unit ---- */}
<section className="mb-7">
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
</section>
{/* ---- detail table ---- */}
<section className="page-break mb-7">
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Assessment detail</h2>
<div className="card scroll-thin overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b bg-[var(--surface-2)]">
{["Incumbent tool", "Capability domain", "Proposed platform", "Annual spend", "Annual saving", "Parity", "Switch", "Payback", "Wave"].map((h, i) => (
<th key={h} className={`px-3 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{r.included.map((c) => {
const m = r.metrics.get(c.id)!;
return (
<tr key={c.id} className="border-b border-[var(--grid)] last:border-0">
<td className="px-3 py-2.5 font-medium">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—"}</td>
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{domainName(c.domain)}</td>
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{c.productIds.map(prodName).join(", ") || "—"}</td>
<td className="px-3 py-2.5 text-right tabnum">{money(c.annualSpend)}</td>
<td className="px-3 py-2.5 text-right tabnum font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings)}</td>
<td className="px-3 py-2.5 text-right tabnum">{c.capability}/5</td>
<td className="px-3 py-2.5 text-right tabnum">{c.switchEase}/5</td>
<td className="px-3 py-2.5 text-right tabnum">{monthsLabel(m.paybackMonths)}</td>
<td className="px-3 py-2.5 text-right">
<span className="chip" style={{ borderColor: WAVE_COLOR[m.wave - 1], color: WAVE_COLOR[m.wave - 1] }}>{m.wave}</span>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 bg-[var(--surface-2)] font-semibold">
<td className="px-3 py-2.5" colSpan={3}>Total — {r.toolsRetired} tools, {r.vendorsRetired} vendors</td>
<td className="px-3 py-2.5 text-right tabnum">{money(r.addressableSpend)}</td>
<td className="px-3 py-2.5 text-right tabnum" style={{ color: "var(--success-text)" }}>{money(r.totalAnnualSavings)}</td>
<td className="px-3 py-2.5" colSpan={4} />
</tr>
</tfoot>
</table>
</div>
</section>
{/* ---- risks ---- */}
{r.included.some((c) => c.risks.trim()) && (
<section className="avoid-break mb-7">
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Risks and dependencies</h2>
<div className="grid gap-3 sm:grid-cols-2">
{r.included.filter((c) => c.risks.trim()).map((c) => (
<div key={c.id} className="card avoid-break p-4">
<div className="text-[13px] font-semibold">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim()}</div>
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-secondary)]">{c.risks}</p>
</div>
))}
</div>
</section>
)}
{/* ---- method ---- */}
<section className="avoid-break border-t pt-5">
<h2 className="mb-2 text-[14px] font-semibold tracking-tight">Method and assumptions</h2>
<p className="max-w-[80ch] text-[12px] leading-relaxed text-[var(--text-muted)]">
Each candidate is scored on capability parity, ease of switching and time to ROI, weighted{" "}
{a.weights.capability}/{a.weights.switchEase}/{a.weights.timeToRoi} with a {a.weights.spend} weighting on
spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into
Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across
the {a.horizonYears}-year horizon; migration cost is treated as a one-time charge in year one. Spend figures
are supplied by or estimated with the customer. These are planning estimates, not a quotation.
</p>
</section>
</>
)}
</article>
);
}

354
src/app/catalog/page.tsx Normal file
View File

@@ -0,0 +1,354 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { BusinessUnit, Catalog, Product } from "@/lib/types";
import { Empty } from "@/components/charts";
import LoadError from "@/components/LoadError";
import { errorMessage, getJson, sendJson } from "@/lib/client";
const BLANK: Product = {
id: "", name: "", bu: "", domain: "", description: "", replaces: [], url: "", enabled: true, notes: "",
};
export default function CatalogPage() {
const [cat, setCat] = useState<Catalog | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const [loadError, setLoadError] = useState("");
const [attempt, setAttempt] = useState(0);
const [q, setQ] = useState("");
const [bu, setBu] = useState("all");
const [domain, setDomain] = useState("all");
const [state, setState] = useState<"all" | "on" | "off">("all");
const [selId, setSelId] = useState<string | null>(null);
const [showBus, setShowBus] = useState(false);
useEffect(() => {
let cancelled = false;
setLoadError("");
getJson<Catalog>("/api/catalog")
.then((c) => { if (!cancelled) setCat(c); })
.catch((e) => { if (!cancelled) setLoadError(errorMessage(e)); });
return () => { cancelled = true; };
}, [attempt]);
function mutate(fn: (c: Catalog) => void) {
setCat((prev) => {
if (!prev) return prev;
const next = structuredClone(prev);
fn(next);
return next;
});
setDirty(true);
setErr("");
}
async function save() {
if (!cat) return;
setSaving(true);
try {
setCat(await sendJson<Catalog>("/api/catalog", "PUT", cat));
setDirty(false);
} catch (e) {
setErr(errorMessage(e));
} finally {
setSaving(false);
}
}
const filtered = useMemo(() => {
if (!cat) return [];
const needle = q.trim().toLowerCase();
return cat.products.filter((p) => {
if (bu !== "all" && p.bu !== bu) return false;
if (domain !== "all" && p.domain !== domain) return false;
if (state === "on" && !p.enabled) return false;
if (state === "off" && p.enabled) return false;
if (!needle) return true;
return (
p.name.toLowerCase().includes(needle) ||
p.description.toLowerCase().includes(needle) ||
p.replaces.some((r) => r.toLowerCase().includes(needle))
);
});
}, [cat, q, bu, domain, state]);
const sel = cat?.products.find((p) => p.id === selId) ?? null;
const buMap = new Map((cat?.businessUnits ?? []).map((b) => [b.id, b]));
const domMap = new Map((cat?.domains ?? []).map((d) => [d.id, d]));
function addProduct() {
if (!cat) return;
let id = "new-product", n = 2;
while (cat.products.some((p) => p.id === id)) id = `new-product-${n++}`;
const p: Product = { ...BLANK, id, name: "New product", bu: cat.businessUnits[0]?.id ?? "", domain: cat.domains[0]?.id ?? "" };
mutate((c) => { c.products.unshift(p); });
setSelId(id);
setQ(""); setBu("all"); setDomain("all"); setState("all");
}
function exportCatalog() {
if (!cat) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(cat, null, 2)], { type: "application/json" }));
const a = document.createElement("a");
a.href = url; a.download = "catalog.json"; a.click();
URL.revokeObjectURL(url);
}
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
if (!cat) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading catalog…</div>;
const enabledCount = cat.products.filter((p) => p.enabled).length;
return (
<div className="fade-up">
<div className="mb-6 flex flex-wrap items-end gap-4">
<div>
<h1 className="text-[28px] font-semibold tracking-tight">Product catalog</h1>
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">
<span className="tabnum font-semibold text-[var(--text-primary)]">{enabledCount}</span> of{" "}
<span className="tabnum">{cat.products.length}</span> products in scope for assessments. Disabled products stay
in the file but never appear in the mapping picker.
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
<button className="btn" onClick={() => setShowBus((v) => !v)}>Business units</button>
<button className="btn" onClick={exportCatalog}>Export</button>
<button className="btn" onClick={addProduct}>+ Add product</button>
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving}>
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
</button>
</div>
</div>
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
{showBus && <BuEditor cat={cat} mutate={mutate} onClose={() => setShowBus(false)} />}
<div className="mb-4 flex flex-wrap items-center gap-2">
<input className="field max-w-[300px]" placeholder="Search products, descriptions, competitors…" value={q} onChange={(e) => setQ(e.target.value)} />
<select className="field max-w-[210px]" value={bu} onChange={(e) => setBu(e.target.value)}>
<option value="all">All business units</option>
{cat.businessUnits.map((b) => <option key={b.id} value={b.id}>{buLabel(b)}</option>)}
</select>
<select className="field max-w-[230px]" value={domain} onChange={(e) => setDomain(e.target.value)}>
<option value="all">All capability domains</option>
{cat.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
{(["all", "on", "off"] as const).map((s) => (
<button key={s}
className={`px-3 py-[7px] text-[12.5px] font-medium ${state === s ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
onClick={() => setState(s)}>
{s === "all" ? "All" : s === "on" ? "In scope" : "Disabled"}
</button>
))}
</div>
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-muted)]">{filtered.length} shown</span>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_400px]">
<div className="card overflow-hidden">
{filtered.length === 0 ? (
<div className="p-8"><Empty>No products match those filters.</Empty></div>
) : (
<div className="scroll-thin max-h-[calc(100vh-260px)] overflow-y-auto">
<table className="w-full text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--surface-1)]">
<tr className="border-b">
<th className="w-10 py-2.5 pl-4 text-left"><span className="sr-only">In scope</span></th>
<th className="py-2.5 text-left font-semibold">Product</th>
<th className="w-[92px] py-2.5 text-left font-semibold">BU</th>
<th className="w-[180px] py-2.5 pr-4 text-left font-semibold">Domain</th>
</tr>
</thead>
<tbody>
{filtered.map((p) => {
const b = buMap.get(p.bu);
return (
<tr key={p.id}
onClick={() => setSelId(p.id)}
className={`cursor-pointer border-b border-[var(--grid)] transition-colors last:border-0 ${
selId === p.id ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"
} ${p.enabled ? "" : "opacity-45"}`}>
<td className="py-2 pl-4" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={p.enabled} aria-label={`${p.name} in scope`}
onChange={(e) => mutate((c) => { c.products.find((x) => x.id === p.id)!.enabled = e.target.checked; })} />
</td>
<td className="py-2 pr-3">
<div className="font-medium">{p.name}</div>
<div className="truncate text-[11.5px] text-[var(--text-muted)]">{p.description}</div>
</td>
<td className="py-2">
<span className="chip" style={{ borderColor: b?.color, color: b?.color }}>{b?.short ?? p.bu ?? "—"}</span>
</td>
<td className="py-2 pr-4 text-[12px] text-[var(--text-secondary)]">{domMap.get(p.domain)?.name ?? p.domain}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="lg:sticky lg:top-[76px] lg:self-start">
{sel ? (
<ProductEditor key={sel.id} cat={cat} p={sel} mutate={mutate}
onDelete={() => { mutate((c) => { c.products = c.products.filter((x) => x.id !== sel.id); }); setSelId(null); }}
onClose={() => setSelId(null)} />
) : (
<div className="card p-8 text-center text-[13px] text-[var(--text-muted)]">
Select a product to edit it, or add a new one.
</div>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------ sub-views */
function ProductEditor({
cat, p, mutate, onDelete, onClose,
}: {
cat: Catalog; p: Product; mutate: (fn: (c: Catalog) => void) => void; onDelete: () => void; onClose: () => void;
}) {
const set = <K extends keyof Product>(k: K, v: Product[K]) =>
mutate((c) => { const t = c.products.find((x) => x.id === p.id); if (t) (t[k] as Product[K]) = v; });
const idTaken = (next: string) => cat.products.some((x) => x.id === next && x.id !== p.id);
return (
<div className="card fade-up flex max-h-[calc(100vh-200px)] flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b px-5 py-3">
<h2 className="truncate text-[14px] font-semibold">{p.name || "Untitled product"}</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close editor">×</button>
</div>
<div className="scroll-thin flex-1 space-y-4 overflow-y-auto p-5">
<label className="flex items-center gap-2.5 rounded-lg bg-[var(--surface-2)] px-3 py-2.5 text-[13px] font-medium">
<input type="checkbox" checked={p.enabled} onChange={(e) => set("enabled", e.target.checked)} />
Include in assessments
</label>
<div>
<label className="label" htmlFor="p-name">Name</label>
<input id="p-name" className="field" value={p.name} onChange={(e) => set("name", e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label" htmlFor="p-bu">Business unit</label>
<select id="p-bu" className="field" value={p.bu} onChange={(e) => set("bu", e.target.value)}>
<option value="">— unassigned —</option>
{cat.businessUnits.map((b) => <option key={b.id} value={b.id}>{buLabel(b)}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="p-domain">Capability domain</label>
<select id="p-domain" className="field" value={p.domain} onChange={(e) => set("domain", e.target.value)}>
<option value="">— unassigned —</option>
{cat.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
</div>
<div>
<label className="label" htmlFor="p-desc">Description</label>
<textarea id="p-desc" className="field" rows={3} value={p.description} onChange={(e) => set("description", e.target.value)} />
</div>
<div>
<label className="label" htmlFor="p-repl">Commonly displaces (one per line)</label>
<textarea id="p-repl" className="field" rows={5} value={p.replaces.join("\n")}
onChange={(e) => set("replaces", e.target.value.split("\n").map((s) => s.trim()).filter(Boolean))} />
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
Drives the suggested-replacement matching when a seller types a competitor tool.
</p>
</div>
<div>
<label className="label" htmlFor="p-url">Reference URL</label>
<input id="p-url" className="field" value={p.url} onChange={(e) => set("url", e.target.value)} />
</div>
<div>
<label className="label" htmlFor="p-notes">Internal notes</label>
<textarea id="p-notes" className="field" rows={2} value={p.notes ?? ""} onChange={(e) => set("notes", e.target.value)} />
</div>
<details className="rounded-lg border px-3 py-2">
<summary className="cursor-pointer text-[12.5px] font-medium text-[var(--text-secondary)]">Advanced: product id</summary>
<input className="field mt-2" value={p.id}
onChange={(e) => {
const next = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-");
if (!next || idTaken(next)) return;
mutate((c) => { const t = c.products.find((x) => x.id === p.id); if (t) t.id = next; });
}} />
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
Assessments reference products by id. Renaming one breaks existing mappings — avoid unless the product is unused.
</p>
</details>
</div>
<div className="border-t px-5 py-3">
<button className="btn btn-sm btn-danger"
onClick={() => { if (confirm(`Remove ${p.name} from the catalog?`)) onDelete(); }}>
Delete product
</button>
</div>
</div>
);
}
// Dropdowns show the full name; fall back to the short code while the name is blank.
function buLabel(b: BusinessUnit) {
return b.name.trim() || b.short.trim() || "Untitled business unit";
}
function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Catalog) => void) => void; onClose: () => void }) {
const [addedId, setAddedId] = useState<string | null>(null);
const counts = new Map<string, number>();
cat.products.forEach((p) => counts.set(p.bu, (counts.get(p.bu) ?? 0) + 1));
return (
<div className="card fade-up mb-4 p-5">
<div className="mb-3 flex items-center">
<h2 className="text-[14px] font-semibold">Business units</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose}>Close</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{cat.businessUnits.map((b) => (
<div key={b.id} className="flex items-center gap-2 rounded-lg border px-2.5 py-2">
<input type="color" className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
value={b.color} aria-label={`${buLabel(b)} colour`}
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.color = e.target.value; })} />
<input className="field py-1 text-[12.5px]" value={b.name} placeholder="Name" aria-label="Business unit name"
autoFocus={b.id === addedId}
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.name = e.target.value; })} />
<input className="field w-[72px] shrink-0 py-1 text-[12.5px]" value={b.short} placeholder="Code" aria-label={`${buLabel(b)} short code`}
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.short = e.target.value; })} />
<span className="shrink-0 tabnum text-[11.5px] text-[var(--text-muted)]">{counts.get(b.id) ?? 0}</span>
<button className="btn btn-ghost btn-sm btn-danger shrink-0"
title={counts.get(b.id) ? "Reassign its products first" : "Delete business unit"}
disabled={!!counts.get(b.id)}
onClick={() => mutate((c) => { c.businessUnits = c.businessUnits.filter((x) => x.id !== b.id); })}>×</button>
</div>
))}
</div>
<button className="btn btn-sm mt-3"
onClick={() => {
let id = "bu", n = 2;
while (cat.businessUnits.some((b) => b.id === id)) id = `bu-${n++}`;
mutate((c) => { c.businessUnits.push({ id, name: "", short: "", color: "#898781" }); });
setAddedId(id);
}}>
+ Add business unit
</button>
</div>
);
}

223
src/app/globals.css Normal file
View File

@@ -0,0 +1,223 @@
@import "tailwindcss";
:root {
color-scheme: light;
--surface-1: #fcfcfb; /* cards, chart surface */
--surface-2: #f4f3f0; /* inset wells, table stripes */
--plane: #f9f9f7; /* page background */
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--grid: #e1e0d9;
--axis: #c3c2b7;
--border: rgba(11, 11, 11, 0.10);
--border-strong: rgba(11, 11, 11, 0.18);
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #4a3aa7;
--series-8: #e34948;
--good: #0ca30c;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
--success-text: #006300;
--accent: #2a78d6;
--accent-ink: #ffffff;
--shadow: 0 1px 2px rgba(11,11,11,.05), 0 8px 24px -12px rgba(11,11,11,.18);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface-1: #1a1a19;
--surface-2: #232322;
--plane: #0d0d0d;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--grid: #2c2c2a;
--axis: #383835;
--border: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.20);
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--success-text: #0ca30c;
--accent: #3987e5;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--surface-1: #1a1a19;
--surface-2: #232322;
--plane: #0d0d0d;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--grid: #2c2c2a;
--axis: #383835;
--border: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.20);
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--success-text: #0ca30c;
--accent: #3987e5;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
}
@theme inline {
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-plane: var(--plane);
--color-ink: var(--text-primary);
--color-ink-2: var(--text-secondary);
--color-ink-3: var(--text-muted);
--color-line: var(--border);
--color-line-strong: var(--border-strong);
--color-accent: var(--accent);
--color-good: var(--good);
--color-warning: var(--warning);
--color-serious: var(--serious);
--color-critical: var(--critical);
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { border-color: var(--border); }
html, body { height: 100%; }
body {
background: var(--plane);
color: var(--text-primary);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
::selection { background: color-mix(in oklab, var(--accent) 28%, transparent); }
/* ---- Form primitives -------------------------------------------------- */
.field {
width: 100%;
background: var(--surface-1);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 7px 10px;
font-size: 13.5px;
color: var(--text-primary);
transition: border-color .12s ease, box-shadow .12s ease;
}
.field::placeholder { color: var(--text-muted); }
.field:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}
.field:disabled { opacity: .5; }
textarea.field { line-height: 1.55; resize: vertical; }
select.field { appearance: none; background-image: none; cursor: pointer; }
.label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 5px;
}
.btn {
display: inline-flex; align-items: center; gap: 6px;
border-radius: 8px; padding: 7px 13px;
font-size: 13px; font-weight: 600;
border: 1px solid var(--border-strong);
background: var(--surface-1); color: var(--text-primary);
cursor: pointer; white-space: nowrap;
transition: background .12s ease, border-color .12s ease, transform .06s ease;
}
.btn:hover { background: var(--surface-2); border-color: var(--text-muted); }
.btn:active { transform: translateY(.5px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: color-mix(in oklab, var(--accent) 86%, #000); border-color: transparent; }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-secondary); }
.btn-ghost:hover { background: var(--surface-2); color: var(--text-primary); }
.btn-danger { color: var(--critical); }
.btn-danger:hover { background: color-mix(in oklab, var(--critical) 12%, transparent); border-color: var(--critical); }
.btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
}
.chip {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11.5px; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--border-strong);
color: var(--text-secondary);
white-space: nowrap;
}
.tabnum { font-variant-numeric: tabular-nums; }
/* Range inputs get a flat, brand-consistent track in both themes. */
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 22px; background: transparent; cursor: pointer; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: var(--grid); }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface-1);
box-shadow: 0 0 0 1px var(--border-strong);
}
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 30%, transparent); }
input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
/* Thin, unobtrusive scrollbars inside panels. */
.scroll-thin { scrollbar-width: thin; scrollbar-color: var(--axis) transparent; }
.scroll-thin::-webkit-scrollbar { width: 9px; height: 9px; }
.scroll-thin::-webkit-scrollbar-thumb { background: var(--axis); border-radius: 999px; border: 2px solid var(--surface-1); }
.scroll-thin::-webkit-scrollbar-track { background: transparent; }
@keyframes fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.fade-up { animation: fade-up .18s ease both; }
/* ---- Print: the customer report ---------------------------------------- */
@media print {
:root { --plane: #ffffff; --surface-1: #ffffff; --surface-2: #f4f3f0; }
.no-print { display: none !important; }
body { background: #fff; }
.card { box-shadow: none; border-color: #d9d8d2; break-inside: avoid; }
.page-break { break-before: page; }
.avoid-break { break-inside: avoid; }
@page { margin: 14mm; size: letter; }
}

13
src/app/icon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<!-- Favicon: the nav logo simplified for 16px — no trend line, heavier bars. -->
<defs>
<linearGradient id="g" x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#2a78d6"/>
<stop offset="1" stop-color="#1baf7a"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#g)"/>
<rect x="5" y="18" width="6" height="9" rx="1.5" fill="#fff" fill-opacity=".6"/>
<rect x="13" y="12" width="6" height="15" rx="1.5" fill="#fff" fill-opacity=".8"/>
<rect x="21" y="5" width="6" height="22" rx="1.5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -0,0 +1,327 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import { CAPABILITY_LABEL, SWITCH_LABEL, money } from "@/lib/format";
import { newEntry } from "@/lib/kb";
import { Empty } from "@/components/charts";
import { ConfidenceChip } from "@/components/IncumbentPicker";
import ProductPicker from "@/components/ProductPicker";
import LoadError from "@/components/LoadError";
import { errorMessage, getJson, sendJson } from "@/lib/client";
type Filter = "all" | "learned" | "seeded" | "unmapped";
export default function KbPage() {
const [kb, setKb] = useState<KnowledgeBase | null>(null);
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const [loadError, setLoadError] = useState("");
const [attempt, setAttempt] = useState(0);
const [q, setQ] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [selId, setSelId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoadError("");
Promise.all([getJson<KnowledgeBase>("/api/knowledge-base"), getJson<Catalog>("/api/catalog")])
.then(([base, cat]) => { if (!cancelled) { setKb(base); setCatalog(cat); } })
.catch((e) => { if (!cancelled) setLoadError(errorMessage(e)); });
return () => { cancelled = true; };
}, [attempt]);
function mutate(fn: (k: KnowledgeBase) => void) {
setKb((prev) => {
if (!prev) return prev;
const next = structuredClone(prev);
fn(next);
return next;
});
setDirty(true);
setErr("");
}
async function save() {
if (!kb) return;
setSaving(true);
try {
setKb(await sendJson<KnowledgeBase>("/api/knowledge-base", "PUT", kb));
setDirty(false);
} catch (e) {
setErr(errorMessage(e));
} finally {
setSaving(false);
}
}
const filtered = useMemo(() => {
if (!kb) return [];
const needle = q.trim().toLowerCase();
return kb.entries
.filter((e) => {
if (filter === "learned" && e.observations.length === 0) return false;
if (filter === "seeded" && e.observations.length > 0) return false;
if (filter === "unmapped" && e.productIds.length > 0) return false;
if (!needle) return true;
return [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase().includes(needle);
})
.sort((a, b) => b.observations.length - a.observations.length || a.label.localeCompare(b.label));
}, [kb, q, filter]);
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
if (!kb || !catalog) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading knowledge base…</div>;
const sel = kb.entries.find((e) => e.id === selId) ?? null;
const learned = kb.entries.filter((e) => e.observations.length > 0).length;
const deals = new Set(kb.entries.flatMap((e) => e.observations.map((o) => o.assessmentId))).size;
function exportKb() {
const url = URL.createObjectURL(new Blob([JSON.stringify(kb, null, 2)], { type: "application/json" }));
const a = document.createElement("a");
a.href = url; a.download = "knowledge-base.json"; a.click();
URL.revokeObjectURL(url);
}
function add() {
const label = prompt("Competitor tool, as a seller would say it (e.g. \"Dynatrace SaaS\")");
if (!label?.trim()) return;
const e = newEntry(label.trim(), "manual");
if (kb!.entries.some((x) => x.id === e.id)) { setSelId(e.id); return; }
mutate((k) => { k.entries.unshift(e); });
setSelId(e.id);
setQ(""); setFilter("all");
}
return (
<div className="fade-up">
<div className="mb-6 flex flex-wrap items-end gap-4">
<div>
<h1 className="text-[28px] font-semibold tracking-tight">Competitive knowledge base</h1>
<p className="mt-1 max-w-3xl text-[13.5px] text-[var(--text-secondary)]">
<span className="tabnum font-semibold text-[var(--text-primary)]">{kb.entries.length}</span> incumbent tools,{" "}
<span className="tabnum font-semibold text-[var(--text-primary)]">{learned}</span> refined from{" "}
<span className="tabnum">{deals}</span> real assessment{deals === 1 ? "" : "s"}. Picking one of these in an
assessment fills the mapping, scores and savings automatically — and every save teaches it more.
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
<button className="btn" onClick={exportKb}>Export</button>
<button className="btn" onClick={add}>+ Add entry</button>
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving}>
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
</button>
</div>
</div>
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
<div className="mb-4 flex flex-wrap items-center gap-2">
<input className="field max-w-[320px]" placeholder="Search incumbent tools…" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
{(["all", "learned", "seeded", "unmapped"] as const).map((f) => (
<button key={f}
className={`px-3 py-[7px] text-[12.5px] font-medium capitalize ${filter === f ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
onClick={() => setFilter(f)}>
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : "Unmapped"}
</button>
))}
</div>
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-muted)]">{filtered.length} shown</span>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
<div className="card overflow-hidden">
{filtered.length === 0 ? (
<div className="p-8"><Empty>Nothing matches those filters.</Empty></div>
) : (
<div className="scroll-thin max-h-[calc(100vh-270px)] overflow-y-auto">
<table className="w-full text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--surface-1)]">
<tr className="border-b">
<th className="py-2.5 pl-4 text-left font-semibold">Incumbent tool</th>
<th className="py-2.5 text-left font-semibold">Maps to</th>
<th className="w-[96px] py-2.5 pr-4 text-right font-semibold">Confidence</th>
</tr>
</thead>
<tbody>
{filtered.map((e) => (
<tr key={e.id} onClick={() => setSelId(e.id)}
className={`cursor-pointer border-b border-[var(--grid)] transition-colors last:border-0 ${selId === e.id ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"}`}>
<td className="py-2 pl-4 pr-3">
<div className="font-medium">{e.label}</div>
<div className="text-[11.5px] text-[var(--text-muted)]">
{catalog.domains.find((d) => d.id === e.domain)?.name ?? "Unassigned domain"}
</div>
</td>
<td className="py-2 pr-3 text-[12px] text-[var(--text-secondary)]">
{e.productIds.length
? e.productIds.map((id) => catalog.products.find((p) => p.id === id)?.name ?? id).join(", ")
: <span className="text-[var(--text-muted)]">—</span>}
</td>
<td className="py-2 pr-4 text-right"><ConfidenceChip entry={e} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="lg:sticky lg:top-[76px] lg:self-start">
{sel ? (
<EntryEditor key={sel.id} entry={sel} catalog={catalog} mutate={mutate}
onDelete={() => { mutate((k) => { k.entries = k.entries.filter((x) => x.id !== sel.id); }); setSelId(null); }}
onClose={() => setSelId(null)} />
) : (
<div className="card p-8 text-center text-[13px] text-[var(--text-muted)]">
Select an incumbent tool to review or curate what the tool will auto-fill.
</div>
)}
</div>
</div>
</div>
);
}
function EntryEditor({
entry, catalog, mutate, onDelete, onClose,
}: {
entry: KbEntry; catalog: Catalog; mutate: (fn: (k: KnowledgeBase) => void) => void;
onDelete: () => void; onClose: () => void;
}) {
const set = <K extends keyof KbEntry>(k: K, v: KbEntry[K]) =>
mutate((base) => { const t = base.entries.find((x) => x.id === entry.id); if (t) (t[k] as KbEntry[K]) = v; });
const obs = entry.observations;
const spend = obs.map((o) => o.annualSpend).filter((n) => n > 0);
return (
<div className="card fade-up flex max-h-[calc(100vh-210px)] flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b px-5 py-3">
<h2 className="truncate text-[14px] font-semibold">{entry.label}</h2>
<ConfidenceChip entry={entry} />
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close editor">×</button>
</div>
<div className="scroll-thin flex-1 space-y-4 overflow-y-auto p-5">
<label className="flex items-start gap-2.5 rounded-lg bg-[var(--surface-2)] px-3 py-2.5 text-[12.5px]">
<input type="checkbox" className="mt-0.5" checked={entry.pinned} onChange={(e) => set("pinned", e.target.checked)} />
<span>
<span className="font-medium">Curate this entry</span>
<span className="block text-[11.5px] text-[var(--text-muted)]">
Locks the values below so auto-learning from future assessments cannot change them.
</span>
</span>
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Vendor</label>
<input className="field" value={entry.vendor} onChange={(e) => set("vendor", e.target.value)} />
</div>
<div>
<label className="label">Product</label>
<input className="field" value={entry.product} onChange={(e) => set("product", e.target.value)} />
</div>
</div>
<div>
<label className="label">Capability domain</label>
<select className="field" value={entry.domain} onChange={(e) => set("domain", e.target.value)}>
<option value="">— unassigned —</option>
{catalog.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div>
<label className="label">Our replacement</label>
<ProductPicker catalog={catalog} value={entry.productIds} onChange={(ids) => set("productIds", ids)} />
</div>
<div className="grid grid-cols-2 gap-3">
<Num label="Capability parity" hint={CAPABILITY_LABEL[entry.capability]} value={entry.capability} min={1} max={5} onChange={(n) => set("capability", n)} />
<Num label="Ease of switching" hint={SWITCH_LABEL[entry.switchEase]} value={entry.switchEase} min={1} max={5} onChange={(n) => set("switchEase", n)} />
<Num label="Time to ROI (months)" value={entry.timeToRoiMonths} min={0} max={48} onChange={(n) => set("timeToRoiMonths", n)} />
<Num label="Spend eliminated (%)" value={entry.savingsPct} min={0} max={100} onChange={(n) => set("savingsPct", n)} />
</div>
<div className="grid grid-cols-2 gap-3">
<Num label="Typical migration cost" value={entry.migrationCost} min={0} step={1000} onChange={(n) => set("migrationCost", n)} />
<div>
<label className="label">Unit</label>
<input className="field" placeholder="seats / cores" value={entry.unitLabel} onChange={(e) => set("unitLabel", e.target.value)} />
</div>
</div>
<div>
<label className="label">Known risks &amp; dependencies</label>
<textarea className="field" rows={3} value={entry.risks} onChange={(e) => set("risks", e.target.value)} />
</div>
<div>
<label className="label">Also known as (one per line)</label>
<textarea className="field" rows={2} value={entry.aliases.join("\n")}
onChange={(e) => set("aliases", e.target.value.split("\n").map((s) => s.trim()).filter(Boolean))} />
</div>
<div>
<label className="label">Battlecard notes</label>
<textarea className="field" rows={3} value={entry.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
{obs.length > 0 && (
<div className="rounded-lg border p-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">
Seen in {obs.length} assessment{obs.length === 1 ? "" : "s"}
</div>
{spend.length > 0 && (
<p className="mb-2 tabnum text-[12px] text-[var(--text-secondary)]">
Annual spend {money(Math.min(...spend), true)} – {money(Math.max(...spend), true)}
</p>
)}
<ul className="space-y-1">
{obs.slice(-6).reverse().map((o) => (
<li key={o.sourceKey} className="flex items-baseline gap-2 text-[12px]">
<Link href={`/assessments/${o.assessmentId}`} className="truncate font-medium hover:underline">{o.customerName}</Link>
<span className="ml-auto shrink-0 tabnum text-[var(--text-muted)]">
{money(o.annualSpend, true)} · {o.savingsPct}% · {o.switchEase}/5
</span>
</li>
))}
</ul>
</div>
)}
</div>
<div className="border-t px-5 py-3">
<button className="btn btn-sm btn-danger"
onClick={() => { if (confirm(`Remove ${entry.label} from the knowledge base?`)) onDelete(); }}>
Delete entry
</button>
</div>
</div>
);
}
function Num({
label, value, min, max, step = 1, hint, onChange,
}: {
label: string; value: number; min: number; max?: number; step?: number; hint?: string; onChange: (n: number) => void;
}) {
return (
<div>
<label className="label">{label}</label>
<input className="field tabnum" type="number" value={value} min={min} max={max} step={step}
onChange={(e) => {
const n = Number(e.target.value);
onChange(Math.max(min, max === undefined ? n : Math.min(max, n)));
}} />
{hint && <div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>}
</div>
);
}

25
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
import Nav from "@/components/Nav";
export const metadata: Metadata = {
title: "Converge",
description: "Map a customer's competitive tool landscape onto our portfolio and build the consolidation business case.",
};
// The theme stamp has to land before first paint or the page flashes the wrong mode.
const themeBoot = `try{var t=localStorage.getItem('prt-theme');if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeBoot }} />
</head>
<body>
<Nav />
<main className="mx-auto w-full max-w-[1600px] px-4 pb-24 sm:px-6">{children}</main>
</body>
</html>
);
}

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

463
src/app/q/[token]/page.tsx Normal file
View File

@@ -0,0 +1,463 @@
"use client";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PublicSurvey, SurveyAnswer, SurveyTool } from "@/lib/types";
import { HttpError, errorMessage, getJson, sendJson } from "@/lib/client";
import { answeredCount, parseSpend } from "@/lib/survey";
import { money } from "@/lib/format";
type Answers = Record<string, SurveyAnswer>;
type Category = PublicSurvey["categories"][number];
const CHIP_LIMIT = 12;
const EMPTY: SurveyAnswer = { status: "", tools: [] };
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const newKey = () => `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
export default function Questionnaire({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const url = `/api/q/${token}`;
const [pub, setPub] = useState<PublicSurvey | null>(null);
const [answers, setAnswers] = useState<Answers>({});
const [name, setName] = useState("");
// -1 is the welcome screen; categories.length is the review screen.
const [step, setStep] = useState(-1);
const [save, setSave] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [loadError, setLoadError] = useState("");
const [notFound, setNotFound] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const latest = useRef({ answers, name });
latest.current = { answers, name };
useEffect(() => {
getJson<PublicSurvey>(url)
.then((p) => {
setPub(p); setAnswers(p.answers); setName(p.respondentName);
// Returning respondents resume at the first category they have not answered.
const next = p.categories.findIndex((c) => !p.answers[c.id]?.status);
if (answeredCount({ domains: p.categories.map((c) => c.id), answers: p.answers }) > 0) {
setStep(next === -1 ? p.categories.length : next);
}
})
.catch((e) => (e instanceof HttpError && e.status === 404 ? setNotFound(true) : setLoadError(errorMessage(e))));
}, [url]);
const flush = useCallback(async () => {
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
setSave("saving");
try {
await sendJson(url, "PUT", { answers: latest.current.answers, respondentName: latest.current.name });
setSave("saved");
} catch (e) {
// Someone reopened or submitted from elsewhere: reload rather than fight it.
if (e instanceof HttpError && e.status === 409) {
const p = await getJson<PublicSurvey>(url).catch(() => null);
if (p) { setPub(p); setAnswers(p.answers); }
}
setSave("error");
}
}, [url]);
const schedule = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setSave("saving");
timer.current = setTimeout(flush, 700);
}, [flush]);
// Don't lose a pending edit if the tab closes mid-debounce.
useEffect(() => {
const onHide = () => {
if (!timer.current) return;
fetch(url, {
method: "PUT", keepalive: true, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answers: latest.current.answers, respondentName: latest.current.name }),
});
};
window.addEventListener("pagehide", onHide);
return () => window.removeEventListener("pagehide", onHide);
}, [url]);
function setAnswer(domain: string, fn: (a: SurveyAnswer) => SurveyAnswer) {
setAnswers((prev) => ({ ...prev, [domain]: fn(prev[domain] ?? EMPTY) }));
schedule();
}
function go(n: number) {
if (timer.current) flush();
setStep(n);
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function submit() {
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
setSubmitting(true); setSubmitError("");
try {
const p = await sendJson<PublicSurvey>(url, "POST", { answers, respondentName: name });
setPub(p); setAnswers(p.answers);
} catch (e) {
setSubmitError(errorMessage(e));
} finally {
setSubmitting(false);
}
}
if (notFound) return <Shell><Centered title="Link not found">This questionnaire link is not valid or has been withdrawn. Ask whoever sent it for a new one.</Centered></Shell>;
if (loadError) return <Shell><Centered title="Could not load the questionnaire">{loadError}<button className="btn mt-4" onClick={() => location.reload()}>Try again</button></Centered></Shell>;
if (!pub) return <Shell><div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div></Shell>;
const cats = pub.categories;
const done = answeredCount({ domains: cats.map((c) => c.id), answers });
const pct = cats.length ? Math.round((done / cats.length) * 100) : 0;
if (pub.status === "submitted") {
return (
<Shell customer={pub.customerName}>
<div className="card fade-up p-8 text-center">
<div className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-full text-[20px]" style={{ background: "color-mix(in oklab, var(--good) 16%, transparent)", color: "var(--success-text)" }}>✓</div>
<h1 className="text-[22px] font-semibold tracking-tight">Thank you{name ? `, ${name.split(" ")[0]}` : ""}</h1>
<p className="mx-auto mt-2 max-w-[440px] text-[14px] text-[var(--text-secondary)]">
Your answers have been submitted. If you need to change anything, ask the person who sent you this link to reopen it.
</p>
</div>
<div className="mt-5"><Summary cats={cats} answers={answers} /></div>
</Shell>
);
}
const saveText = { idle: "", saving: "Saving…", saved: "Saved", error: "Not saved — retrying on next change" }[save];
return (
<Shell customer={pub.customerName} right={<span className="text-[12px]" style={{ color: save === "error" ? "var(--critical)" : "var(--text-muted)" }}>{saveText}</span>}>
{step >= 0 && (
<div className="mb-5">
<div className="mb-1.5 flex text-[12px] text-[var(--text-muted)]">
<span>{step < cats.length ? `Category ${step + 1} of ${cats.length}` : "Review"}</span>
<span className="ml-auto tabnum">{done} of {cats.length} answered</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--grid)]">
<div className="h-full rounded-full bg-[var(--accent)] transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
</div>
)}
{step === -1 ? (
<Welcome pub={pub} name={name} setName={(v) => { setName(v); schedule(); }} onStart={() => go(0)} />
) : (
<div className="grid gap-5 lg:grid-cols-[230px_minmax(0,1fr)]">
<Rail cats={cats} answers={answers} step={step} go={go} />
{step < cats.length ? (
<CategoryStep
key={cats[step].id}
cat={cats[step]}
answer={answers[cats[step].id] ?? EMPTY}
onChange={(fn) => setAnswer(cats[step].id, fn)}
onBack={() => go(step - 1)}
onNext={() => go(step + 1)}
isLast={step === cats.length - 1}
/>
) : (
<div className="fade-up">
<div className="card mb-4 p-5">
<h2 className="text-[20px] font-semibold tracking-tight">Review and submit</h2>
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">
{done < cats.length
? `${cats.length - done} categor${cats.length - done === 1 ? "y is" : "ies are"} unanswered. That's fine — skipped categories are simply left blank.`
: "Everything is answered. Check it over, then submit."}
</p>
<div className="mt-4 max-w-[320px]">
<label className="label" htmlFor="q-name-review">Your name</label>
<input id="q-name-review" className="field" value={name} placeholder="Optional"
onChange={(e) => { setName(e.target.value); schedule(); }} />
</div>
</div>
<Summary cats={cats} answers={answers} onEdit={(i) => go(i)} />
{submitError && <p className="mt-3 text-[13px]" style={{ color: "var(--critical)" }}>{submitError}</p>}
<div className="mt-5 flex gap-2">
<button className="btn" onClick={() => go(cats.length - 1)}>← Back</button>
<button className="btn btn-primary ml-auto" onClick={submit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit answers"}
</button>
</div>
</div>
)}
</div>
)}
</Shell>
);
}
/* ------------------------------------------------------------------ pieces */
function Shell({ children, customer, right }: { children: React.ReactNode; customer?: string; right?: React.ReactNode }) {
return (
<div className="mx-auto w-full max-w-[980px] pt-8">
<div className="mb-6 flex items-center gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[.06em] text-[var(--text-muted)]">Technology landscape</div>
{customer && <div className="truncate text-[15px] font-semibold tracking-tight">{customer}</div>}
</div>
<div className="ml-auto">{right}</div>
</div>
{children}
</div>
);
}
function Centered({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="card p-10 text-center">
<h1 className="text-[17px] font-semibold">{title}</h1>
<div className="mt-2 text-[13.5px] text-[var(--text-secondary)]">{children}</div>
</div>
);
}
function Welcome({ pub, name, setName, onStart }: { pub: PublicSurvey; name: string; setName: (v: string) => void; onStart: () => void }) {
const mins = Math.max(3, Math.round(pub.categories.length * 0.5));
return (
<div className="card fade-up p-7 sm:p-9">
<h1 className="text-[26px] font-semibold tracking-tight">
{pub.respondentName ? `Hi ${pub.respondentName.split(" ")[0]} — ` : ""}help us map your tool landscape
</h1>
<p className="mt-3 max-w-[620px] text-[14.5px] leading-relaxed text-[var(--text-secondary)]">
We&rsquo;ll walk through <strong className="text-[var(--text-primary)]">{pub.categories.length} technology categories</strong>, one at a time.
For each, pick the tools {pub.customerName} uses today. Annual spend and contract end dates help, but they&rsquo;re optional, and so is every question.
</p>
<ul className="mt-5 grid max-w-[620px] gap-2 text-[13.5px] text-[var(--text-secondary)] sm:grid-cols-3">
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">⏱ About {mins} minutes</li>
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">💾 Saves as you go</li>
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">↩ Come back any time</li>
</ul>
<div className="mt-6 max-w-[320px]">
<label className="label" htmlFor="q-name">Your name</label>
<input id="q-name" className="field" value={name} placeholder="Optional" onChange={(e) => setName(e.target.value)} />
</div>
<button className="btn btn-primary mt-6 px-5 py-2.5 text-[14px]" onClick={onStart}>Start →</button>
</div>
);
}
function Rail({ cats, answers, step, go }: { cats: Category[]; answers: Answers; step: number; go: (n: number) => void }) {
return (
<nav className="hidden lg:block" aria-label="Categories">
<ol className="sticky top-6 space-y-0.5">
{cats.map((c, i) => {
const a = answers[c.id];
const dot = a?.status === "tools" ? "var(--accent)" : a?.status ? "var(--text-muted)" : "transparent";
return (
<li key={c.id}>
<button onClick={() => go(i)}
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[12.5px] ${i === step ? "bg-[var(--surface-2)] font-semibold text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}>
<span className="h-2 w-2 shrink-0 rounded-full border" style={{ background: dot, borderColor: a?.status ? dot : "var(--border-strong)" }} />
<span className="truncate">{c.name}</span>
{a?.status === "tools" && <span className="ml-auto tabnum text-[11px] text-[var(--text-muted)]">{a.tools.length}</span>}
</button>
</li>
);
})}
<li className="pt-1">
<button onClick={() => go(cats.length)}
className={`w-full rounded-lg px-2.5 py-1.5 text-left text-[12.5px] ${step === cats.length ? "bg-[var(--surface-2)] font-semibold" : "text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}>
Review &amp; submit
</button>
</li>
</ol>
</nav>
);
}
function CategoryStep({
cat, answer, onChange, onBack, onNext, isLast,
}: {
cat: Category;
answer: SurveyAnswer;
onChange: (fn: (a: SurveyAnswer) => SurveyAnswer) => void;
onBack: () => void;
onNext: () => void;
isLast: boolean;
}) {
const [q, setQ] = useState("");
const [showAll, setShowAll] = useState(false);
const picked = new Set(answer.tools.map((t) => t.label.toLowerCase()));
const needle = q.trim().toLowerCase();
const visible = useMemo(() => {
const list = needle ? cat.options.filter((o) => o.label.toLowerCase().includes(needle)) : cat.options;
return showAll || needle ? list : list.slice(0, CHIP_LIMIT);
}, [cat.options, needle, showAll]);
const exact = cat.options.some((o) => o.label.toLowerCase() === needle);
function toggle(label: string, kbEntryId: string) {
onChange((a) => {
const has = a.tools.find((t) => t.label.toLowerCase() === label.toLowerCase());
const tools = has
? a.tools.filter((t) => t !== has)
: [...a.tools, { key: newKey(), label, kbEntryId, annualSpend: null, contractEnd: "" }];
return { status: tools.length ? "tools" : "", tools };
});
}
function addTyped() {
const label = q.trim().replace(/\s+/g, " ");
if (!label) return;
const match = cat.options.find((o) => o.label.toLowerCase() === label.toLowerCase());
if (!picked.has(label.toLowerCase())) toggle(match?.label ?? label, match?.id ?? "");
setQ("");
}
const setTool = (key: string, patch: Partial<SurveyTool>) =>
onChange((a) => ({ ...a, tools: a.tools.map((t) => (t.key === key ? { ...t, ...patch } : t)) }));
const setStatus = (status: "none" | "unsure") =>
onChange((a) => (a.status === status ? { status: "", tools: [] } : { status, tools: [] }));
return (
<div className="card fade-up p-6 sm:p-7">
<h2 className="text-[22px] font-semibold tracking-tight">{cat.name}</h2>
{cat.description && <p className="mt-1.5 text-[14px] text-[var(--text-secondary)]">{cat.description}</p>}
<p className="mt-6 text-[13.5px] font-semibold">What do you use for this today? <span className="font-normal text-[var(--text-muted)]">Pick all that apply.</span></p>
<div className="mt-3 flex flex-wrap gap-2">
{visible.map((o) => {
const on = picked.has(o.label.toLowerCase());
return (
<button key={o.id} onClick={() => toggle(o.label, o.id)} aria-pressed={on}
className="rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors"
style={on
? { background: "var(--accent)", borderColor: "var(--accent)", color: "#fff" }
: { borderColor: "var(--border-strong)", color: "var(--text-primary)" }}>
{on ? "✓ " : ""}{o.label}
</button>
);
})}
{!needle && cat.options.length > CHIP_LIMIT && (
<button className="rounded-full px-3 py-1.5 text-[13px] font-medium text-[var(--accent)] hover:bg-[var(--surface-2)]" onClick={() => setShowAll((v) => !v)}>
{showAll ? "Show fewer" : `+ ${cat.options.length - CHIP_LIMIT} more`}
</button>
)}
{needle && visible.length === 0 && <span className="py-1.5 text-[13px] text-[var(--text-muted)]">No match in our list — add it as typed.</span>}
</div>
<div className="mt-3 flex max-w-[480px] gap-2">
<input className="field" placeholder="Search, or type a tool that isn't listed…" value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTyped(); } }} />
<button className="btn" onClick={addTyped} disabled={!q.trim() || exact && picked.has(needle)}>Add</button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{(["none", "unsure"] as const).map((s) => (
<button key={s} onClick={() => setStatus(s)} aria-pressed={answer.status === s}
className="rounded-full border px-3 py-1.5 text-[13px] font-medium"
style={answer.status === s
? { background: "var(--text-primary)", borderColor: "var(--text-primary)", color: "var(--plane)" }
: { borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}>
{s === "none" ? "Nothing in place" : "Not sure"}
</button>
))}
</div>
{answer.tools.length > 0 && (
<div className="mt-6 border-t pt-5">
<p className="mb-3 text-[13.5px] font-semibold">Details <span className="font-normal text-[var(--text-muted)]">— optional, rough numbers are fine</span></p>
<ul className="space-y-2.5">
{answer.tools.map((t) => <ToolDetail key={t.key} t={t} onChange={(p) => setTool(t.key, p)} onRemove={() => toggle(t.label, t.kbEntryId)} />)}
</ul>
</div>
)}
<div className="mt-7 flex items-center gap-2 border-t pt-5">
<button className="btn" onClick={onBack}>← Back</button>
<button className="btn btn-primary ml-auto" onClick={onNext}>
{answer.status ? (isLast ? "Review →" : "Next →") : "Skip →"}
</button>
</div>
</div>
);
}
function ToolDetail({ t, onChange, onRemove }: { t: SurveyTool; onChange: (p: Partial<SurveyTool>) => void; onRemove: () => void }) {
const [spendText, setSpendText] = useState(t.annualSpend === null ? "" : money(t.annualSpend));
const [bad, setBad] = useState(false);
const [year, month] = t.contractEnd ? t.contractEnd.split("-") : ["", ""];
const thisYear = new Date().getFullYear();
const years = Array.from({ length: 8 }, (_, i) => String(thisYear - 1 + i));
function setEnd(y: string, m: string) {
// Store only a complete month; a half-picked date waits for its other half.
onChange({ contractEnd: y && m ? `${y}-${m}` : "" });
}
const [pendY, setPendY] = useState(year);
const [pendM, setPendM] = useState(month);
return (
<li className="grid items-end gap-3 rounded-xl border p-3.5 sm:grid-cols-[minmax(0,1fr)_170px_220px_auto]">
<div className="min-w-0 self-center text-[14px] font-medium">{t.label}</div>
<div>
<label className="label" htmlFor={`s-${t.key}`}>Annual spend</label>
<input id={`s-${t.key}`} className="field tabnum" inputMode="decimal" placeholder="e.g. 250k" value={spendText}
style={bad ? { borderColor: "var(--critical)" } : undefined}
onChange={(e) => {
setSpendText(e.target.value);
const v = parseSpend(e.target.value);
setBad(!!e.target.value.trim() && v === null);
if (v !== null || !e.target.value.trim()) onChange({ annualSpend: v });
}}
onBlur={() => { if (t.annualSpend !== null && !bad) setSpendText(money(t.annualSpend)); }} />
</div>
<div>
<span className="label">Contract ends</span>
<div className="flex gap-1.5">
<select className="field" aria-label={`${t.label} contract end month`} value={pendM}
onChange={(e) => { setPendM(e.target.value); setEnd(pendY, e.target.value); }}>
<option value="">Month</option>
{MONTHS.map((m, i) => <option key={m} value={String(i + 1).padStart(2, "0")}>{m}</option>)}
</select>
<select className="field" aria-label={`${t.label} contract end year`} value={pendY}
onChange={(e) => { setPendY(e.target.value); setEnd(e.target.value, pendM); }}>
<option value="">Year</option>
{(years.includes(pendY) || !pendY ? years : [pendY, ...years]).map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
</div>
<button className="btn btn-ghost btn-sm self-center justify-self-end" onClick={onRemove} aria-label={`Remove ${t.label}`}>Remove</button>
</li>
);
}
function Summary({ cats, answers, onEdit }: { cats: Category[]; answers: Answers; onEdit?: (i: number) => void }) {
return (
<div className="card divide-y">
{cats.map((c, i) => {
const a = answers[c.id] ?? EMPTY;
return (
<div key={c.id} className="flex items-start gap-3 px-5 py-3">
<div className="w-[38%] shrink-0 text-[13px] font-medium">{c.name}</div>
<div className="min-w-0 flex-1 text-[13px] text-[var(--text-secondary)]">
{a.status === "tools" ? (
<ul className="space-y-0.5">
{a.tools.map((t) => (
<li key={t.key}>
<span className="text-[var(--text-primary)]">{t.label}</span>
{(t.annualSpend !== null || t.contractEnd) && (
<span className="text-[var(--text-muted)]">
{" · "}{[t.annualSpend !== null ? `${money(t.annualSpend, true)}/yr` : "", t.contractEnd ? `ends ${MONTHS[+t.contractEnd.slice(5) - 1]} ${t.contractEnd.slice(0, 4)}` : ""].filter(Boolean).join(" · ")}
</span>
)}
</li>
))}
</ul>
) : a.status === "none" ? "Nothing in place" : a.status === "unsure" ? "Not sure" : <span className="text-[var(--text-muted)]">Skipped</span>}
</div>
{onEdit && <button className="btn btn-ghost btn-sm shrink-0" onClick={() => onEdit(i)}>Edit</button>}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { AccountIntel } from "@/lib/types";
import { dateLabel, num } from "@/lib/format";
/**
* Internal-only context from the account dossier.
*
* Marked `no-print` and never referenced by the customer report: the source
* document mixes customer-safe facts with internal pricing strategy, competitive
* risk and personal details.
*/
export default function AccountIntelStrip({ intel, onClear }: { intel: AccountIntel; onClear: () => void }) {
const [open, setOpen] = useState(false);
const headline = [
["Classification", intel.classification],
["Status", intel.accountStatus],
["Sentiment", intel.sentiment],
["Renewal", intel.renewalTimeframe],
["Renewal risk", intel.renewalRisk],
["Deployment", intel.deploymentRate],
].filter(([, v]) => v) as [string, string][];
const shelfware = intel.consumption.filter((c) => c.entitled > 0 && c.deployed === 0);
return (
<section className="no-print card mb-5 overflow-hidden">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<span className="chip" style={{ borderColor: "var(--warning)", color: "var(--warning)" }}>Internal only</span>
<span className="text-[13px] font-semibold">Account intelligence</span>
{intel.sourceUrl && (
<a className="max-w-[280px] truncate text-[12px] text-[var(--accent)] hover:underline"
href={intel.sourceUrl} target="_blank" rel="noreferrer noopener">
{intel.sourceTitle || "Open dossier"}
</a>
)}
<span className="text-[11.5px] text-[var(--text-muted)]">Imported {dateLabel(intel.importedAt)}</span>
<div className="ml-auto flex gap-1.5">
<button className="btn btn-ghost btn-sm" onClick={() => setOpen((v) => !v)}>{open ? "Hide" : "Details"}</button>
<button className="btn btn-ghost btn-sm btn-danger"
onClick={() => { if (confirm("Remove the imported account intelligence from this assessment?")) onClear(); }}>
Remove
</button>
</div>
</div>
{headline.length > 0 && (
<dl className="flex flex-wrap gap-x-6 gap-y-2 border-t px-4 py-2.5">
{headline.map(([k, v]) => (
<div key={k}>
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
<dd className="text-[12.5px]">{v}</dd>
</div>
))}
</dl>
)}
{open && (
<div className="fade-up grid gap-4 border-t p-4 lg:grid-cols-3">
{shelfware.length > 0 && (
<Panel title={`Entitled, not deployed (${shelfware.length})`}
hint="Already paid for — adoption plays that need no new licence spend.">
<ul className="space-y-1">
{shelfware.map((s) => (
<li key={s.product} className="flex items-baseline gap-2 text-[12.5px]">
<span className="truncate font-medium">{s.product}</span>
<span className="ml-auto shrink-0 tabnum text-[var(--text-muted)]">{num(s.entitled)}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.infrastructure.length > 0 && (
<Panel title="Infrastructure estate" hint="The vendor per layer, as the dossier records it.">
<ul className="space-y-1">
{[...intel.infrastructure, ...intel.cloud].map((f) => (
<li key={f.label} className="flex items-baseline gap-2 text-[12.5px]">
<span className="shrink-0 text-[var(--text-muted)]">{f.label}</span>
<span className="ml-auto truncate text-right font-medium">{f.value}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.applications.length > 0 && (
<Panel title={`Application estate (${intel.applications.length})`} hint="Grouped as the dossier groups them.">
<ul className="scroll-thin max-h-[190px] space-y-1 overflow-y-auto">
{intel.applications.map((app) => (
<li key={`${app.category}-${app.name}`} className="flex items-baseline gap-2 text-[12.5px]">
<span className="truncate">{app.name}</span>
<span className="ml-auto shrink-0 text-[11px] text-[var(--text-muted)]">{app.category}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.narratives.length > 0 && (
<div className="lg:col-span-3">
<Panel title="Analyst assessment" hint="Background for the executive summary — not customer-facing copy.">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{intel.narratives.map((n) => (
<div key={n.heading}>
<div className="text-[12px] font-semibold">{n.heading}</div>
<p className="mt-0.5 text-[12px] leading-relaxed text-[var(--text-secondary)]">{n.body}</p>
</div>
))}
</div>
</Panel>
</div>
)}
</div>
)}
</section>
);
}
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
return (
<div className="rounded-xl bg-[var(--surface-2)] p-3">
<h4 className="text-[12px] font-semibold">{title}</h4>
<p className="mb-2 text-[11px] text-[var(--text-muted)]">{hint}</p>
{children}
</div>
);
}

306
src/components/Analysis.tsx Normal file
View File

@@ -0,0 +1,306 @@
"use client";
import { useMemo, useState } from "react";
import type { Assessment, Catalog } from "@/lib/types";
import type { Rollup } from "@/lib/scoring";
import { money } from "@/lib/format";
import { ChartFrame, DataTable, Empty, StatTile } from "@/components/charts";
import { type Plan, type PlanItem, DEFAULT_PARALLEL, DURATION_BY_EASE, buildPlan, monthLabel, quarterLabel } from "@/lib/timeline";
/* -------------------------------------------------------- tool footprint */
export function ToolFootprintTile({ r }: { r: Rollup }) {
const cut = r.toolsRetired - r.toolsAfter;
const pct = r.toolsRetired ? Math.round((cut / r.toolsRetired) * 100) : 0;
return (
<StatTile
label="Tool footprint"
value={`${r.toolsRetired} → ${r.toolsAfter}`}
tone={cut > 0 ? "good" : "neutral"}
sub={`${cut > 0 ? `${cut} fewer tools (${pct}%)` : cut < 0 ? `${-cut} more products than tools` : "Same tool count"} · ${r.vendorsRetired} vendors → ${r.vendorsAfter}${r.unmappedCount ? ` · ${r.unmappedCount} unmapped` : ""}`}
/>
);
}
/* ------------------------------------------------- value by business unit */
export function BuBreakdown({ r, catalog, horizonYears }: { r: Rollup; catalog: Catalog; horizonYears: number }) {
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
const total = r.byBu.reduce((s, b) => s + b.savings, 0);
const max = Math.max(1, ...r.byBu.map((b) => b.savings));
return (
<ChartFrame
title="Value by business unit"
subtitle={`Where the displaced spend lands in our portfolio. A tool mapped to two units splits its value between them.`}
table={{
headers: ["Business unit", "Tools displaced", "Our products", "Current spend", "Annual saving", "Migration", `${horizonYears}-yr net`],
rows: r.byBu.map((b) => [b.name, b.count, b.productIds.length, money(b.spend), money(b.savings), money(b.migrationCost), money(b.horizonNet)]),
}}
>
{r.byBu.length === 0 ? <Empty>Map replacement products to see this.</Empty> : (
<>
{/* share of annual saving, as one stacked bar */}
{total > 0 && (
<div className="mb-4 flex h-2.5 w-full overflow-hidden rounded-full bg-[var(--grid)]" role="img"
aria-label={r.byBu.map((b) => `${b.short} ${Math.round((b.savings / total) * 100)}%`).join(", ")}>
{r.byBu.filter((b) => b.savings > 0).map((b) => (
<div key={b.buId} style={{ width: `${(b.savings / total) * 100}%`, background: b.color }}
title={`${b.name}: ${Math.round((b.savings / total) * 100)}% of annual saving`} />
))}
</div>
)}
<ul className="divide-y divide-[var(--grid)]">
{r.byBu.map((b) => (
<li key={b.buId} className="grid items-center gap-x-4 gap-y-1 py-2.5 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_repeat(3,96px)]">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: b.color }} />
<span className="truncate text-[13px] font-semibold">{b.name}</span>
</div>
<div className="mt-0.5 truncate pl-[18px] text-[11.5px] text-[var(--text-muted)]" title={b.productIds.map(prodName).join(", ")}>
{b.count} tool{b.count === 1 ? "" : "s"} → {b.productIds.map(prodName).join(", ")}
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--grid)]">
<div className="h-full rounded-full" style={{ width: `${Math.max(1.5, (b.savings / max) * 100)}%`, background: b.color }} />
</div>
<Num label="Spend" v={money(b.spend, true)} />
<Num label="Saving / yr" v={money(b.savings, true)} strong />
<Num label={`${horizonYears}-yr net`} v={money(b.horizonNet, true)} />
</li>
))}
</ul>
</>
)}
</ChartFrame>
);
}
function Num({ label, v, strong }: { label: string; v: string; strong?: boolean }) {
return (
<div className="text-right">
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className={`tabnum text-[13px] ${strong ? "font-semibold" : ""}`} style={strong ? { color: "var(--success-text)" } : undefined}>{v}</div>
</div>
);
}
/* --------------------------------------------------- implementation timeline */
const PHASE_MONTHS = 6;
export function ImplementationTimeline({
a, r, catalog, onParallel,
}: {
a: Assessment;
r: Rollup;
catalog: Catalog;
/** Workbench only: lets the seller tune how many migrations run at once. */
onParallel?: (n: number) => void;
}) {
const [open, setOpen] = useState(false);
const [asTable, setAsTable] = useState(false);
const plan = useMemo(() => buildPlan(a, r, catalog), [a, r, catalog]);
const L = (n: number, long = false) => monthLabel(plan.startIso, n, long);
const summary = plan.items.length
? `${plan.items.length} projects · ${L(0)} – ${L(plan.completion - 1)} · full run-rate savings from ${L(plan.fullRunRate)}`
: "Map replacement products to build a plan.";
return (
<section className="card avoid-break mb-5 overflow-hidden">
<button className="flex w-full items-center gap-3 px-5 py-4 text-left hover:bg-[var(--surface-2)]"
aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<span className="text-[12px] text-[var(--text-muted)] transition-transform no-print" style={{ transform: open ? "rotate(90deg)" : "none" }}>▶</span>
<span className="min-w-0">
<span className="block text-[14px] font-semibold tracking-tight">Implementation timeline</span>
<span className="block truncate text-[12.5px] text-[var(--text-muted)]">{summary}</span>
</span>
{plan.bridgeCount > 0 && (
<span className="chip ml-auto shrink-0" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
{plan.bridgeCount} need a bridge extension
</span>
)}
</button>
{/* Collapsed on screen, but always part of the printed report. */}
<div className={open ? "border-t p-5" : "hidden border-t p-5 print:block"}>
{plan.items.length === 0 ? <Empty>Map replacement products to build a plan.</Empty> : (
<>
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Mini label="Program" value={`${plan.completion} months`} sub={`${L(0)} – ${L(plan.completion - 1)}`} />
<Mini label="Full run-rate savings" value={L(plan.fullRunRate)} sub={`${money(plan.ramp[plan.ramp.length - 1] ?? 0, true)} per year once complete`} />
<Mini label={`Realized in ${a.horizonYears} years`} value={money(plan.realizedInHorizon, true)}
sub={`${plan.flatInHorizon ? Math.round((plan.realizedInHorizon / plan.flatInHorizon) * 100) : 0}% of the ${money(plan.flatInHorizon, true)} flat model, after ramp-up`} />
<Mini label="Contract-driven" value={`${plan.items.filter((i) => i.anchor === "contract").length} of ${plan.items.length}`}
sub={plan.bridgeCount ? `${plan.bridgeCount} expire before migration can finish` : "Every dated contract is met"} />
</div>
<div className="no-print mb-3 flex flex-wrap items-center gap-2">
{onParallel && (
<>
<span className="text-[12px] text-[var(--text-secondary)]">Parallel migrations per business unit</span>
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
{[1, 2, 3, 4].map((n) => (
<button key={n} onClick={() => onParallel(n)}
className={`px-2.5 py-1 text-[12px] font-semibold ${(a.planParallel ?? DEFAULT_PARALLEL) === n ? "bg-[var(--accent)] text-white" : "hover:bg-[var(--surface-2)]"}`}>{n}</button>
))}
</div>
</>
)}
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setAsTable((v) => !v)}>{asTable ? "Timeline" : "Table"}</button>
</div>
{asTable ? <PlanTable plan={plan} /> : <Gantt plan={plan} catalog={catalog} />}
<p className="mt-4 text-[11.5px] leading-relaxed text-[var(--text-muted)]">
Tools with a known contract end are scheduled to cut over by that date. The rest are sequenced with known
savings first, then by wave and size of saving, with {a.planParallel ?? DEFAULT_PARALLEL} migration{(a.planParallel ?? DEFAULT_PARALLEL) === 1 ? "" : "s"} in
flight per business unit. Migration length follows ease of switching ({Object.entries(DURATION_BY_EASE).reverse().map(([k, v]) => `${k}/5: ${v} mo`).join(", ")}).
Savings start at cutover, or at contract end if later.
{plan.unmappedCount > 0 && ` ${plan.unmappedCount} unmapped tool${plan.unmappedCount === 1 ? " is" : "s are"} left out until a replacement is chosen.`}
</p>
</>
)}
</div>
</section>
);
}
function Mini({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div className="rounded-xl bg-[var(--surface-2)] px-3.5 py-3">
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className="mt-1 text-[18px] font-semibold tracking-tight">{value}</div>
<div className="mt-0.5 text-[11.5px] text-[var(--text-secondary)]">{sub}</div>
</div>
);
}
function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
const span = plan.span;
const pct = (m: number) => `${(Math.min(Math.max(m, 0), span) / span) * 100}%`;
// Quarter ticks, labelled at each quarter boundary inside the span.
const ticks: number[] = [];
const firstMonth = new Date(`${plan.startIso}T00:00:00Z`).getUTCMonth();
for (let m = 0; m <= span; m++) if ((firstMonth + m) % 3 === 0) ticks.push(m);
const phases = new Map<number, PlanItem[]>();
for (const i of plan.items) {
const p = Math.floor(i.start / PHASE_MONTHS);
phases.set(p, [...(phases.get(p) ?? []), i]);
}
const buLegend = catalog.businessUnits.filter((b) => plan.items.some((i) => i.color === b.color));
const peak = Math.max(1, ...plan.ramp);
const Track = ({ children, className = "" }: { children?: React.ReactNode; className?: string }) => (
<div className={`relative ${className}`}>
{ticks.map((m) => <div key={m} className="absolute inset-y-0 w-px bg-[var(--grid)]" style={{ left: pct(m) }} />)}
{children}
</div>
);
return (
<div className="scroll-thin overflow-x-auto">
<div className="min-w-[760px]">
{/* axis */}
<div className="grid grid-cols-[240px_minmax(0,1fr)] border-b pb-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Project</div>
<div className="relative h-4">
{ticks.map((m) => (
<span key={m} className="absolute -translate-x-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-muted)]" style={{ left: pct(m) }}>
{quarterLabel(plan.startIso, m)}
</span>
))}
</div>
</div>
{[...phases.entries()].map(([p, items]) => (
<div key={p}>
<div className="grid grid-cols-[240px_minmax(0,1fr)] bg-[var(--surface-2)]">
<div className="px-1 py-1.5 text-[11px] font-semibold">
Phase {p + 1} <span className="font-normal text-[var(--text-muted)]">· starts {monthLabel(plan.startIso, p * PHASE_MONTHS)} – {monthLabel(plan.startIso, p * PHASE_MONTHS + PHASE_MONTHS - 1)}</span>
</div>
<div className="self-center pr-1 text-right text-[11px] tabnum text-[var(--text-secondary)]">
{items.length} project{items.length === 1 ? "" : "s"} · {money(items.reduce((s, i) => s + i.annualSavings, 0), true)}/yr unlocked
</div>
</div>
{items.map((i) => (
<div key={i.id} className="grid grid-cols-[240px_minmax(0,1fr)] border-b border-[var(--grid)]">
<div className="min-w-0 py-1.5 pr-3">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12.5px] font-medium">{i.label}</span>
{i.bridge && <span className="shrink-0 text-[11px]" style={{ color: "var(--warning)" }} title="Contract ends before this migration can finish — plan a short bridge extension">⚠</span>}
</div>
<div className="truncate text-[11px] text-[var(--text-muted)]">→ {i.products}</div>
</div>
<Track className="h-full min-h-[38px]">
{/* waiting for the contract to run out before savings start */}
{i.savingsFrom > i.end && (
<div className="absolute top-1/2 h-0 -translate-y-1/2 border-t-2 border-dashed" style={{ left: pct(i.end), width: `calc(${pct(i.savingsFrom)} - ${pct(i.end)})`, borderColor: i.color, opacity: 0.6 }} />
)}
<div className="absolute top-1/2 flex h-4 -translate-y-1/2 items-center rounded-[5px]"
title={`${i.label}: ${monthLabel(plan.startIso, i.start)} → cutover ${monthLabel(plan.startIso, i.end - 1)} · ${money(i.annualSavings)}/yr from ${monthLabel(plan.startIso, i.savingsFrom)}`}
style={{
left: pct(i.start), width: `calc(${pct(i.end)} - ${pct(i.start)})`, background: i.color,
...(i.bridge ? { backgroundImage: "repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.35) 5px 8px)" } : {}),
}} />
{i.annualSavings > 0 && (
<span className="absolute top-1/2 ml-1.5 -translate-y-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-secondary)]"
style={{ left: pct(Math.max(i.end, i.savingsFrom)) }}>{money(i.annualSavings, true)}/yr</span>
)}
{i.contractEnd !== null && (
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 border-2"
style={{ left: pct(i.contractEnd + 1), borderColor: "var(--text-primary)", background: "var(--surface-1)" }}
title={`Contract ends ${monthLabel(plan.startIso, i.contractEnd, true)}`} />
)}
</Track>
</div>
))}
</div>
))}
{/* run-rate savings ramp */}
<div className="grid grid-cols-[240px_minmax(0,1fr)] pt-2">
<div className="pr-3 text-[11px]">
<div className="font-semibold">Run-rate savings</div>
<div className="text-[var(--text-muted)]">{money(peak, true)}/yr at full run-rate</div>
</div>
<Track className="h-14">
<svg className="absolute inset-0 h-full w-full" viewBox={`0 0 ${span} 100`} preserveAspectRatio="none" aria-hidden>
<path
d={`M0 100 ${plan.ramp.map((v, m) => `L${m} ${100 - (v / peak) * 92} L${m + 1} ${100 - (v / peak) * 92}`).join(" ")} L${span} ${100 - ((plan.ramp[plan.ramp.length - 1] ?? 0) / peak) * 92} L${span} 100 Z`}
fill="var(--good)" fillOpacity=".18" stroke="var(--success-text)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
</Track>
</div>
{/* legend */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-[var(--text-secondary)]">
{buLegend.map((b) => (
<span key={b.id} className="flex items-center gap-1.5"><span className="h-2.5 w-4 rounded-sm" style={{ background: b.color }} />{b.short}</span>
))}
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rotate-45 border-2 border-[var(--text-primary)]" />Contract ends</span>
<span className="flex items-center gap-1.5"><span className="w-4 border-t-2 border-dashed border-[var(--text-muted)]" />Waiting for contract end</span>
<span className="flex items-center gap-1.5"><span style={{ color: "var(--warning)" }}>⚠</span>Bridge extension needed</span>
</div>
</div>
</div>
);
}
function PlanTable({ plan }: { plan: Plan }) {
const L = (n: number) => monthLabel(plan.startIso, n);
return (
<DataTable
headers={["Project", "Replacement", "Start", "Cutover", "Contract ends", "Savings from", "Annual saving"]}
rows={plan.items.map((i) => [
`${i.label}${i.bridge ? " ⚠" : ""}`, i.products, L(i.start), L(i.end - 1),
i.contractEnd === null ? "—" : L(i.contractEnd), L(i.savingsFrom), money(i.annualSavings),
])}
/>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import type { CandidateMetrics } from "@/lib/scoring";
import { CAPABILITY_LABEL, SWITCH_LABEL, money, monthsLabel } from "@/lib/format";
import ProductPicker, { suggest } from "./ProductPicker";
import IncumbentPicker, { ConfidenceChip } from "./IncumbentPicker";
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
function Slider({
id, label, value, min, max, step = 1, hint, onChange, suffix,
}: {
id: string; label: string; value: number; min: number; max: number; step?: number;
hint?: string; suffix?: string; onChange: (n: number) => void;
}) {
return (
<div>
<div className="mb-1 flex items-baseline gap-2">
<label className="label mb-0" htmlFor={id}>{label}</label>
<span className="ml-auto tabnum text-[12.5px] font-semibold">{value}{suffix}</span>
</div>
<input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
{hint && <div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>}
</div>
);
}
export default function CandidateRow({
c, m, catalog, kb, onChange, onDelete, onDuplicate,
}: {
c: Candidate; m: CandidateMetrics; catalog: Catalog; kb: KnowledgeBase | null;
onChange: (next: Candidate) => void; onDelete: () => void; onDuplicate: () => void;
}) {
const [open, setOpen] = useState(!c.incumbentProduct);
const [applied, setApplied] = useState<KbEntry | null>(null);
const set = <K extends keyof Candidate>(k: K, v: Candidate[K]) => onChange({ ...c, [k]: v });
/** Applying an entry fills the row in one write so autosave sees a single change. */
function applyEntry(e: KbEntry) {
onChange({
...c,
incumbentVendor: e.vendor,
incumbentProduct: e.product,
domain: e.domain || c.domain,
productIds: e.productIds.length ? [...e.productIds] : c.productIds,
capability: e.capability,
switchEase: e.switchEase,
timeToRoiMonths: e.timeToRoiMonths,
savingsPct: e.savingsPct,
// Spend and quantity are always customer-specific, so they are never prefilled.
migrationCost: e.migrationCost || c.migrationCost,
unitLabel: e.unitLabel || c.unitLabel,
risks: c.risks.trim() || e.risks,
});
setApplied(e);
}
const suggestions = suggest(catalog, `${c.incumbentVendor} ${c.incumbentProduct}`, c.domain)
.filter((p) => !c.productIds.includes(p.id));
const mapped = c.productIds.map((id) => catalog.products.find((p) => p.id === id)).filter(Boolean);
const title = [c.incumbentVendor, c.incumbentProduct].filter(Boolean).join(" ") || "Untitled incumbent tool";
return (
<div className={`card avoid-break overflow-hidden transition-opacity ${c.included ? "" : "opacity-55"}`}>
<div className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-[var(--surface-2)]"
onClick={() => setOpen((v) => !v)}>
<span className="w-1.5 self-stretch rounded-full" style={{ background: WAVE_COLOR[m.wave - 1] }} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[14px] font-semibold tracking-tight">{title}</span>
{!c.included && <span className="chip">Excluded</span>}
</div>
<div className="truncate text-[12px] text-[var(--text-muted)]">
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
</div>
</div>
<div className="hidden shrink-0 items-center gap-5 text-right sm:flex">
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend</div>
<div className="tabnum text-[13px] font-semibold">{money(c.annualSpend, true)}</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</div>
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Payback</div>
<div className="tabnum text-[13px] font-semibold">{monthsLabel(m.paybackMonths)}</div>
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="tabnum rounded-lg px-2 py-1 text-[13px] font-semibold"
style={{ background: `color-mix(in oklab, ${WAVE_COLOR[m.wave - 1]} 16%, transparent)`, color: WAVE_COLOR[m.wave - 1] }}
title={`Rationalization score ${m.score.toFixed(0)} of 100 — Wave ${m.wave}`}>
{m.score.toFixed(0)}
</span>
<span className="text-[var(--text-muted)]">{open ? "▴" : "▾"}</span>
</div>
</div>
{open && (
<div className="fade-up border-t px-4 py-4">
<div className="grid gap-4 lg:grid-cols-2">
{/* ---- Incumbent ---- */}
<div className="space-y-3">
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Incumbent tool</h4>
<IncumbentPicker
kb={kb} catalog={catalog}
vendor={c.incumbentVendor} product={c.incumbentProduct}
onType={(field, value) => { setApplied(null); set(field, value); }}
onPick={applyEntry}
/>
{applied && (
<div className="fade-up flex items-center gap-2 rounded-lg bg-[var(--surface-2)] px-2.5 py-2 text-[11.5px] text-[var(--text-secondary)]">
<ConfidenceChip entry={applied} />
<span>Filled from the knowledge base. Adjust anything — your values are learned back.</span>
</div>
)}
<div>
<label className="label" htmlFor={`d-${c.id}`}>Capability domain</label>
<select id={`d-${c.id}`} className="field" value={c.domain} onChange={(e) => set("domain", e.target.value)}>
<option value="">— choose —</option>
{catalog.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="label" htmlFor={`s-${c.id}`}>Annual spend (USD)</label>
<input id={`s-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.annualSpend} onChange={(e) => set("annualSpend", Math.max(0, Number(e.target.value)))} />
</div>
<div>
<label className="label" htmlFor={`u-${c.id}`}>Quantity</label>
<input id={`u-${c.id}`} className="field tabnum" type="number" min={0} value={c.units} onChange={(e) => set("units", Math.max(0, Number(e.target.value)))} />
</div>
<div>
<label className="label" htmlFor={`ul-${c.id}`}>Unit</label>
<input id={`ul-${c.id}`} className="field" placeholder="seats / cores" value={c.unitLabel} onChange={(e) => set("unitLabel", e.target.value)} />
</div>
</div>
<div>
<label className="label" htmlFor={`r-${c.id}`}>Contract renewal</label>
<input id={`r-${c.id}`} className="field" type="date" value={c.renewalDate} onChange={(e) => set("renewalDate", e.target.value)} />
</div>
</div>
{/* ---- Replacement ---- */}
<div className="space-y-3">
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Our replacement</h4>
<ProductPicker catalog={catalog} value={c.productIds} onChange={(ids) => set("productIds", ids)} />
{suggestions.length > 0 && (
<div className="rounded-lg bg-[var(--surface-2)] p-2.5">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Suggested</div>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((p) => (
<button key={p.id} className="btn btn-sm" title={p.description}
onClick={() => set("productIds", [...c.productIds, p.id])}>+ {p.name}</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label" htmlFor={`sp-${c.id}`}>Spend eliminated</label>
<input id={`sp-${c.id}`} className="field tabnum" type="number" min={0} max={100} value={c.savingsPct}
onChange={(e) => set("savingsPct", Math.min(100, Math.max(0, Number(e.target.value))))} />
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">% of the {money(c.annualSpend, true)} line</div>
</div>
<div>
<label className="label" htmlFor={`mc-${c.id}`}>Migration cost (USD)</label>
<input id={`mc-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.migrationCost}
onChange={(e) => set("migrationCost", Math.max(0, Number(e.target.value)))} />
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">One-time services + effort</div>
</div>
</div>
</div>
</div>
{/* ---- Scoring ---- */}
<div className="mt-5 grid gap-5 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
<Slider id={`cap-${c.id}`} label="Capability parity" value={c.capability} min={1} max={5}
hint={CAPABILITY_LABEL[c.capability]} onChange={(n) => set("capability", n)} />
<Slider id={`se-${c.id}`} label="Ease of switching" value={c.switchEase} min={1} max={5}
hint={SWITCH_LABEL[c.switchEase]} onChange={(n) => set("switchEase", n)} />
<Slider id={`roi-${c.id}`} label="Time to ROI" value={c.timeToRoiMonths} min={0} max={48} suffix=" mo"
hint={c.timeToRoiMonths <= 6 ? "Fast — scores full marks" : c.timeToRoiMonths >= 36 ? "Beyond the scoring horizon" : "Within the scoring horizon"}
onChange={(n) => set("timeToRoiMonths", n)} />
<Slider id={`sv-${c.id}`} label="Strategic value" value={c.strategicValue} min={1} max={5}
hint="Non-financial importance to the customer" onChange={(n) => set("strategicValue", n)} />
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor={`ri-${c.id}`}>Risks & dependencies</label>
<textarea id={`ri-${c.id}`} className="field" rows={2} placeholder="Integrations, skills, contract lock-in…"
value={c.risks} onChange={(e) => set("risks", e.target.value)} />
</div>
<div>
<label className="label" htmlFor={`no-${c.id}`}>Internal notes</label>
<textarea id={`no-${c.id}`} className="field" rows={2} placeholder="Not shown in the customer report"
value={c.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
<label className="flex items-center gap-2 text-[12.5px] font-medium">
<input type="checkbox" checked={c.included} onChange={(e) => set("included", e.target.checked)} />
Include in totals and the customer report
</label>
<div className="ml-auto flex items-center gap-2">
<span className="tabnum text-[12px] text-[var(--text-muted)]">
{money(m.annualSavings)}/yr · {monthsLabel(m.paybackMonths)} payback
{m.roiPct !== null && ` · ${m.roiPct.toFixed(0)}% ROI`}
</span>
<button className="btn btn-sm" onClick={onDuplicate}>Duplicate</button>
<button className="btn btn-sm btn-danger" onClick={() => { if (confirm(`Remove ${title}?`)) onDelete(); }}>Delete</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import { searchKb } from "@/lib/kb";
/**
* Typeahead over the competitive knowledge base.
*
* Typing filters known incumbent tools; picking one applies its learned
* defaults — product mapping, scores, savings and migration cost — in one go.
*/
export default function IncumbentPicker({
kb, catalog, vendor, product, onType, onPick,
}: {
kb: KnowledgeBase | null;
catalog: Catalog;
vendor: string;
product: string;
onType: (field: "incumbentVendor" | "incumbentProduct", value: string) => void;
onPick: (entry: KbEntry) => void;
}) {
const [open, setOpen] = useState(false);
const [focus, setFocus] = useState<"vendor" | "product" | null>(null);
const [cursor, setCursor] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
const query = [vendor, product].filter(Boolean).join(" ");
const matches = useMemo(() => (kb ? searchKb(kb, query, 8) : []), [kb, query]);
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
useEffect(() => { setCursor(0); }, [query]);
function choose(e: KbEntry) {
onPick(e);
setOpen(false);
setFocus(null);
}
function onKeyDown(ev: React.KeyboardEvent) {
if (!open || matches.length === 0) return;
if (ev.key === "ArrowDown") { ev.preventDefault(); setCursor((c) => (c + 1) % matches.length); }
else if (ev.key === "ArrowUp") { ev.preventDefault(); setCursor((c) => (c - 1 + matches.length) % matches.length); }
else if (ev.key === "Enter") { ev.preventDefault(); choose(matches[cursor]); }
else if (ev.key === "Escape") { setOpen(false); }
}
return (
<div className="relative" ref={boxRef}
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label" htmlFor={`v-${vendor}-${product}`}>Vendor</label>
<input
className="field" placeholder="e.g. Splunk" value={vendor}
aria-label="Incumbent vendor" autoComplete="off"
onChange={(e) => { onType("incumbentVendor", e.target.value); setOpen(true); }}
onFocus={() => { setFocus("vendor"); setOpen(true); }}
onKeyDown={onKeyDown}
/>
</div>
<div>
<label className="label">Product</label>
<input
className="field" placeholder="e.g. Enterprise" value={product}
aria-label="Incumbent product" autoComplete="off"
onChange={(e) => { onType("incumbentProduct", e.target.value); setOpen(true); }}
onFocus={() => { setFocus("product"); setOpen(true); }}
onKeyDown={onKeyDown}
/>
</div>
</div>
{open && focus && matches.length > 0 && (
<div className="absolute z-30 mt-1.5 w-full rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-1.5 shadow-[var(--shadow)]">
<div className="px-2 pb-1 pt-0.5 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">
Known incumbents — picking one fills the whole row
</div>
<ul className="scroll-thin max-h-[290px] overflow-y-auto">
{matches.map((e, i) => (
<li key={e.id}>
<button
type="button"
className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left ${i === cursor ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"}`}
onMouseEnter={() => setCursor(i)}
onClick={() => choose(e)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{e.label}</span>
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">
{e.productIds.length ? `→ ${e.productIds.map(prodName).join(", ")}` : "No mapping yet"}
</span>
</span>
<ConfidenceChip entry={e} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
export function ConfidenceChip({ entry }: { entry: KbEntry }) {
const n = entry.observations.length;
if (entry.pinned) {
return <span className="chip shrink-0" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }} title="Curated entry — auto-learning will not overwrite it">Curated</span>;
}
if (n === 0) {
return <span className="chip shrink-0" title="Seeded from the product catalog; not yet used in a deal">Seeded</span>;
}
return (
<span className="chip shrink-0" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
title={`Learned from ${n} assessment${n === 1 ? "" : "s"}`}>
{n} deal{n === 1 ? "" : "s"}
</span>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import type { Catalog, IntelImportResult, IntelProposal } from "@/lib/types";
import { errorMessage, sendJson } from "@/lib/client";
import { num } from "@/lib/format";
const CONFIDENCE_STYLE: Record<IntelProposal["confidence"], { color: string; label: string }> = {
high: { color: "var(--success-text)", label: "High" },
medium: { color: "var(--warning)", label: "Medium" },
low: { color: "var(--text-muted)", label: "Low" },
};
export default function IntelImport({
catalog, onClose, onAccept,
}: {
catalog: Catalog;
onClose: () => void;
onAccept: (result: IntelImportResult, chosen: IntelProposal[]) => void;
}) {
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [result, setResult] = useState<IntelImportResult | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
async function analyse() {
setBusy(true); setErr("");
try {
const r = await sendJson<IntelImportResult>("/api/assessments/x/intel", "POST", { text, sourceUrl: url });
setResult(r);
// Low-confidence guesses start unticked; the seller opts in to those.
setPicked(new Set(r.proposals.filter((p) => p.confidence !== "low").map((p) => p.key)));
} catch (e) {
setErr(errorMessage(e));
} finally {
setBusy(false);
}
}
const toggle = (k: string) =>
setPicked((prev) => {
const next = new Set(prev);
next.has(k) ? next.delete(k) : next.add(k);
return next;
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Import account intelligence"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<h2 className="text-[15px] font-semibold tracking-tight">Import account intelligence</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
{!result ? (
<div className="space-y-4 p-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
Open the account dossier, select all (<kbd className="rounded bg-[var(--surface-2)] px-1">⌘A</kbd>) and copy
(<kbd className="rounded bg-[var(--surface-2)] px-1">⌘C</kbd>), then paste below. The dossier stays internal —
only the rows you accept become part of the assessment, and none of its commentary reaches the customer report.
</p>
<div>
<label className="label" htmlFor="intel-url">Dossier link</label>
<input id="intel-url" className="field" placeholder="https://docs.google.com/document/d/…"
value={url} onChange={(e) => setUrl(e.target.value)} />
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
Stored on the assessment so it always points back at its source.
</p>
</div>
<div>
<label className="label" htmlFor="intel-text">Dossier text</label>
<textarea id="intel-text" className="field font-mono text-[12px]" rows={12}
placeholder="Paste the full dossier here…" value={text} onChange={(e) => setText(e.target.value)} />
<p className="mt-1 tabnum text-[11.5px] text-[var(--text-muted)]">
{text.length ? `${num(text.length)} characters` : "Nothing pasted yet"}
</p>
</div>
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
<div className="flex justify-end gap-2">
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={analyse} disabled={!text.trim() || busy}>
{busy ? "Analysing…" : "Analyse dossier"}
</button>
</div>
</div>
) : (
<div className="p-5">
<Summary result={result} />
{result.warnings.length > 0 && (
<ul className="mb-4 space-y-1 rounded-lg border px-3 py-2.5" style={{ borderColor: "var(--warning)" }}>
{result.warnings.map((w) => (
<li key={w} className="text-[12.5px]" style={{ color: "var(--text-secondary)" }}>⚠ {w}</li>
))}
</ul>
)}
{result.proposals.length > 0 && (
<>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[13px] font-semibold">
Incumbent tools found — {picked.size} of {result.proposals.length} selected
</h3>
<button className="btn btn-ghost btn-sm ml-auto"
onClick={() => setPicked(new Set(picked.size === result.proposals.length ? [] : result.proposals.map((p) => p.key)))}>
{picked.size === result.proposals.length ? "Clear all" : "Select all"}
</button>
</div>
<ul className="scroll-thin mb-4 max-h-[300px] space-y-1.5 overflow-y-auto">
{result.proposals.map((p) => {
const c = CONFIDENCE_STYLE[p.confidence];
return (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(p.key)} onChange={() => toggle(p.key)} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="text-[13px] font-medium">{p.label}</span>
<span className="chip" style={{ borderColor: c.color, color: c.color }}>{c.label}</span>
</span>
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
→ {p.productIds.length ? p.productIds.map(prodName).join(", ") : "No enabled replacement — map it manually"}
</span>
<span className="mt-0.5 block text-[11.5px] text-[var(--text-muted)]">
Dossier says — {p.evidence}
</span>
</span>
</label>
</li>
);
})}
</ul>
</>
)}
{result.shelfware.length > 0 && (
<div className="mb-4 rounded-lg bg-[var(--surface-2)] p-3">
<h3 className="mb-1.5 text-[13px] font-semibold">
Entitled but not deployed — {result.shelfware.length} product{result.shelfware.length === 1 ? "" : "s"}
</h3>
<p className="mb-2 text-[12px] text-[var(--text-secondary)]">
Already paid for. These are adoption plays, not displacements, so they are kept separately from the candidates.
</p>
<ul className="flex flex-wrap gap-1.5">
{result.shelfware.map((s) => (
<li key={s.product} className="chip">{s.product} · {num(s.entitled)} entitled</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2 border-t pt-4">
<button className="btn" onClick={() => setResult(null)}>Back</button>
<button className="btn btn-primary"
onClick={() => onAccept(result, result.proposals.filter((p) => picked.has(p.key)))}>
Add {picked.size} tool{picked.size === 1 ? "" : "s"} &amp; save intelligence
</button>
</div>
</div>
)}
</div>
</div>
);
}
function Summary({ result }: { result: IntelImportResult }) {
const i = result.intel;
const facts = [
["Company", i.company], ["Industry", i.industry], ["Classification", i.classification],
["Renewal", i.renewalTimeframe], ["Renewal risk", i.renewalRisk], ["Sentiment", i.sentiment],
["Deployment rate", i.deploymentRate], ["Applications", i.applications.length ? String(i.applications.length) : ""],
].filter(([, v]) => v);
return (
<div className="mb-4 rounded-lg border p-3">
<h3 className="mb-2 text-[13px] font-semibold">{i.sourceTitle || "Dossier"}</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-4">
{facts.map(([k, v]) => (
<div key={k}>
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
<dd className="truncate text-[12.5px]" title={v}>{v}</dd>
</div>
))}
</dl>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function LoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="card p-10 text-center">
<p className="text-[14px] font-semibold" style={{ color: "var(--critical)" }}>Could not load this page</p>
<p className="mx-auto mt-2 max-w-[54ch] text-[13px] text-[var(--text-secondary)]">{message}</p>
<div className="mt-5 flex justify-center gap-2">
<button className="btn btn-primary" onClick={onRetry}>Retry</button>
<Link className="btn" href="/">Back to assessments</Link>
</div>
</div>
);
}

26
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { useId } from "react";
/**
* App mark: rising bars under an upward trend line — consolidation turning into
* savings. Fixed colours rather than theme tokens so it reads the same on light
* and dark, and matches the favicon (src/app/icon.svg).
*/
export default function Logo({ size = 24 }: { size?: number }) {
const id = useId();
return (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden>
<defs>
<linearGradient id={id} x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
<stop stopColor="#2a78d6" />
<stop offset="1" stopColor="#1baf7a" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill={`url(#${id})`} />
<rect x="7" y="19" width="4" height="6" rx="1" fill="#fff" fillOpacity=".55" />
<rect x="14" y="15" width="4" height="10" rx="1" fill="#fff" fillOpacity=".75" />
<rect x="21" y="11" width="4" height="14" rx="1" fill="#fff" />
<path d="M6 15.5 12.5 10.5 17.5 12.5 25.5 5.5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 5.5h4.5V10" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

56
src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import ThemeToggle from "./ThemeToggle";
const LINKS = [
{ href: "/", label: "Assessments" },
{ href: "/knowledge-base", label: "Knowledge base" },
{ href: "/catalog", label: "Product catalog" },
];
export default function Nav() {
const path = usePathname();
const isReport = path.endsWith("/report");
// Questionnaire respondents get a standalone page with no route into the tool.
if (path.startsWith("/q/")) return null;
return (
<header
className={`no-print sticky top-0 z-40 mb-6 border-b bg-[var(--plane)]/85 backdrop-blur-xl ${isReport ? "print:hidden" : ""}`}
>
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-2 px-4 sm:px-6">
<Link href="/" className="mr-3 flex items-center gap-2.5">
<Logo size={24} />
<span className="text-[14px] font-semibold tracking-tight">Converge</span>
</Link>
<nav className="flex items-center gap-1">
{LINKS.map((l) => {
const active = l.href === "/" ? path === "/" || path.startsWith("/assessments") : path.startsWith(l.href);
return (
<Link
key={l.href}
href={l.href}
className={`rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors ${
active
? "bg-[var(--surface-2)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="ml-auto">
<ThemeToggle />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useMemo, useRef, useState } from "react";
import type { Catalog } from "@/lib/types";
/** Ranks catalog products against free-text about the incumbent tool. */
export function suggest(catalog: Catalog, incumbent: string, domain: string, limit = 6) {
const needle = incumbent.trim().toLowerCase();
if (!needle) return [];
const words = needle.split(/\s+/).filter((w) => w.length > 2);
return catalog.products
.filter((p) => p.enabled)
.map((p) => {
let score = 0;
for (const r of p.replaces) {
const rl = r.toLowerCase();
if (rl === needle) score += 100;
else if (rl.includes(needle) || needle.includes(rl)) score += 60;
else if (words.some((w) => rl.includes(w))) score += 22;
}
if (domain && p.domain === domain) score += 12;
return { p, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || a.p.name.localeCompare(b.p.name))
.slice(0, limit)
.map((x) => x.p);
}
export default function ProductPicker({
catalog, value, onChange,
}: {
catalog: Catalog; value: string[]; onChange: (ids: string[]) => void;
}) {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const byId = useMemo(() => new Map(catalog.products.map((p) => [p.id, p])), [catalog]);
const buById = useMemo(() => new Map(catalog.businessUnits.map((b) => [b.id, b])), [catalog]);
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
return catalog.products
.filter((p) => p.enabled && !value.includes(p.id))
.filter((p) => !needle || p.name.toLowerCase().includes(needle) || p.replaces.some((r) => r.toLowerCase().includes(needle)))
.slice(0, 40);
}, [catalog, q, value]);
return (
<div className="relative" ref={boxRef}
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
<div className="flex flex-wrap gap-1.5">
{value.map((id) => {
const p = byId.get(id);
const b = p ? buById.get(p.bu) : undefined;
return (
<span key={id} className="chip" style={{ borderColor: b?.color }}>
<span className="inline-block h-2 w-2 rounded-full" style={{ background: b?.color ?? "var(--text-muted)" }} />
{p?.name ?? id}
<button className="ml-0.5 text-[var(--text-muted)] hover:text-[var(--critical)]"
aria-label={`Remove ${p?.name ?? id}`} onClick={() => onChange(value.filter((v) => v !== id))}>×</button>
</span>
);
})}
<button className="btn btn-sm" onClick={() => setOpen((v) => !v)}>+ Map product</button>
</div>
{open && (
<div className="absolute z-30 mt-2 w-[min(440px,92vw)] rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-2 shadow-[var(--shadow)]">
<input autoFocus className="field mb-2" placeholder="Search our portfolio…" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="scroll-thin max-h-[300px] overflow-y-auto">
{matches.length === 0 ? (
<p className="px-2 py-4 text-center text-[12.5px] text-[var(--text-muted)]">
No enabled products match. Check the catalog filters.
</p>
) : matches.map((p) => {
const b = buById.get(p.bu);
return (
<button key={p.id}
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-2)]"
onClick={() => { onChange([...value, p.id]); setQ(""); }}>
<span className="mt-[5px] inline-block h-2 w-2 shrink-0 rounded-full" style={{ background: b?.color }} />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{p.name}</span>
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">{b?.short} · {p.description}</span>
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useMemo, useState } from "react";
import type { Catalog, Survey } from "@/lib/types";
import { errorMessage, sendJson } from "@/lib/client";
import { answeredCount, pendingTools, surveyTools, surveyableDomains } from "@/lib/survey";
import { dateLabel } from "@/lib/format";
/** Create, share and track landscape questionnaires for one assessment. */
export default function SurveyPanel({
assessmentId, catalog, surveys, onChanged, onReview, onClose,
}: {
assessmentId: string;
catalog: Catalog;
surveys: Survey[];
onChanged: () => void;
onReview: (s: Survey) => void;
onClose: () => void;
}) {
const domains = useMemo(() => surveyableDomains(catalog), [catalog]);
const [creating, setCreating] = useState(surveys.length === 0);
const [name, setName] = useState("");
const [picked, setPicked] = useState<Set<string>>(() => new Set(domains.map((d) => d.id)));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [copied, setCopied] = useState("");
const base = `/api/assessments/${assessmentId}/surveys`;
const origin = typeof window === "undefined" ? "" : window.location.origin;
const isLocal = /\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin);
const linkFor = (s: Survey) => `${origin}/q/${s.id}`;
async function run(fn: () => Promise<unknown>) {
setBusy(true); setErr("");
try { await fn(); onChanged(); } catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
}
function create() {
run(async () => {
const s = await sendJson<Survey>(base, "POST", { respondentName: name, domains: [...picked] });
setCreating(false); setName("");
copy(s);
});
}
async function copy(s: Survey) {
try {
await navigator.clipboard.writeText(linkFor(s));
setCopied(s.id);
setTimeout(() => setCopied((c) => (c === s.id ? "" : c)), 2000);
} catch {
// Clipboard can be blocked; the link is visible to copy by hand.
}
}
const toggle = (id: string) => setPicked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Landscape questionnaires"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<h2 className="text-[15px] font-semibold tracking-tight">Landscape questionnaires</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="space-y-4 p-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
Send a colleague a link. They step through one category at a time, pick the tools in place, and add
spend and contract dates if they know them. Submitted answers wait in a review inbox here. Nothing reaches the
assessment until you accept it.
</p>
{isLocal && (
<p className="rounded-lg border px-3 py-2 text-[12.5px]" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
⚠ You&rsquo;re on <code>{origin}</code>, so links will point at <em>localhost</em> and only work on this machine.
Open the app using this computer&rsquo;s network name or IP address before copying links for colleagues.
</p>
)}
{surveys.length > 0 && (
<ul className="space-y-2">
{surveys.map((s) => {
const total = s.domains.length;
const done = answeredCount(s);
const pending = pendingTools(s).length;
const tools = surveyTools(s).length;
return (
<li key={s.id} className="rounded-xl border p-3.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13.5px] font-semibold">{s.respondentName || "Unnamed respondent"}</span>
{s.status === "submitted" ? (
pending > 0
? <span className="chip" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}>Submitted · {pending} to review</span>
: <span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}>Reviewed</span>
) : (
<span className="chip">{done ? `In progress · ${done}/${total}` : "Not started"}</span>
)}
<span className="text-[12px] text-[var(--text-muted)]">
{total} categories · {tools} tool{tools === 1 ? "" : "s"} named
{s.submittedAt ? ` · submitted ${dateLabel(s.submittedAt)}` : ` · created ${dateLabel(s.createdAt)}`}
</span>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<input className="field max-w-[420px] py-1 font-mono text-[11.5px]" readOnly value={linkFor(s)}
aria-label="Questionnaire link" onFocus={(e) => e.currentTarget.select()} />
<button className="btn btn-sm" onClick={() => copy(s)}>{copied === s.id ? "Copied ✓" : "Copy link"}</button>
<a className="btn btn-sm" href={`/q/${s.id}`} target="_blank" rel="noreferrer">Open ↗</a>
<span className="ml-auto flex gap-2">
{pending > 0 && <button className="btn btn-primary btn-sm" onClick={() => onReview(s)}>Review</button>}
{s.status === "submitted" && (
<button className="btn btn-sm" disabled={busy} title="Let the respondent edit and submit again"
onClick={() => run(() => sendJson(`${base}/${s.id}`, "PUT", { action: "reopen" }))}>Reopen</button>
)}
<button className="btn btn-ghost btn-sm btn-danger" disabled={busy}
onClick={() => { if (confirm("Delete this questionnaire? Its link stops working. Tools you already accepted stay in the assessment.")) run(() => sendJson(`${base}/${s.id}`, "DELETE")); }}>
Delete
</button>
</span>
</div>
</li>
);
})}
</ul>
)}
{creating ? (
<div className="rounded-xl border p-4">
<h3 className="mb-3 text-[13.5px] font-semibold">New questionnaire</h3>
<div className="mb-4 max-w-[340px]">
<label className="label" htmlFor="sv-name">Who is it for?</label>
<input id="sv-name" className="field" placeholder="e.g. Dana Lee, CIO" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="mb-2 flex items-center gap-2">
<span className="label mb-0">Categories to ask about · {picked.size} of {domains.length}</span>
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setPicked(new Set(domains.map((d) => d.id)))}>All</button>
<button className="btn btn-ghost btn-sm" onClick={() => setPicked(new Set())}>None</button>
</div>
<div className="scroll-thin grid max-h-[300px] gap-1 overflow-y-auto sm:grid-cols-2">
{domains.map((d) => (
<label key={d.id} className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(d.id)} onChange={() => toggle(d.id)} />
<span className="text-[12.5px]">{d.name}</span>
</label>
))}
</div>
<div className="mt-4 flex justify-end gap-2">
{surveys.length > 0 && <button className="btn" onClick={() => setCreating(false)}>Cancel</button>}
<button className="btn btn-primary" onClick={create} disabled={busy || picked.size === 0}>
{busy ? "Creating…" : "Create & copy link"}
</button>
</div>
</div>
) : (
<button className="btn" onClick={() => setCreating(true)}>+ New questionnaire</button>
)}
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useMemo, useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase, Survey } from "@/lib/types";
import { type PendingTool, candidateKey, pendingTools } from "@/lib/survey";
import { candidateLabel, splitVendor } from "@/lib/kb";
import { dateLabel, money } from "@/lib/format";
/** The knowledge-base entry a respondent's answer refers to, by id or by name. */
export function kbEntryFor(kb: KnowledgeBase | null, t: Pick<PendingTool, "kbEntryId" | "label">): KbEntry | null {
if (!kb) return null;
const byId = t.kbEntryId ? kb.entries.find((e) => e.id === t.kbEntryId) : undefined;
if (byId) return byId;
const l = t.label.toLowerCase();
return kb.entries.find((e) => e.label.toLowerCase() === l || e.aliases.some((a) => a.toLowerCase() === l)) ?? null;
}
export function toolVendorProduct(kb: KnowledgeBase | null, t: PendingTool) {
const e = kbEntryFor(kb, t);
return e ? { vendor: e.vendor, product: e.product } : splitVendor(t.label);
}
/** Review inbox for one submitted questionnaire. */
export default function SurveyReview({
survey, catalog, kb, candidates, onAccept, onClose,
}: {
survey: Survey;
catalog: Catalog;
kb: KnowledgeBase | null;
candidates: Candidate[];
onAccept: (accepted: PendingTool[], dismissed: PendingTool[]) => void;
onClose: () => void;
}) {
const tools = useMemo(() => pendingTools(survey), [survey]);
const [picked, setPicked] = useState<Set<string>>(() => new Set(tools.map((t) => t.key)));
const existing = useMemo(() => new Set(candidates.map((c) => candidateLabel(c).toLowerCase())), [candidates]);
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
const byDomain = survey.domains
.map((d) => ({ d, items: tools.filter((t) => t.domain === d) }))
.filter((g) => g.items.length);
const noneIn = survey.domains.filter((d) => survey.answers[d]?.status === "none");
const unsure = survey.domains.filter((d) => survey.answers[d]?.status === "unsure");
const toggle = (k: string) => setPicked((prev) => {
const next = new Set(prev);
next.has(k) ? next.delete(k) : next.add(k);
return next;
});
const accepted = tools.filter((t) => picked.has(t.key));
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Review questionnaire"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Review questionnaire</h2>
<p className="text-[12px] text-[var(--text-muted)]">
{survey.respondentName || "Unnamed respondent"} · submitted {dateLabel(survey.submittedAt)}
</p>
</div>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="p-5">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[13px] font-semibold">Tools named — {picked.size} of {tools.length} selected</h3>
<button className="btn btn-ghost btn-sm ml-auto"
onClick={() => setPicked(new Set(picked.size === tools.length ? [] : tools.map((t) => t.key)))}>
{picked.size === tools.length ? "Clear all" : "Select all"}
</button>
</div>
<div className="scroll-thin mb-4 max-h-[420px] space-y-3 overflow-y-auto">
{byDomain.map(({ d, items }) => (
<div key={d}>
<div className="mb-1 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{domainName(d)}</div>
<ul className="space-y-1.5">
{items.map((t) => {
const entry = kbEntryFor(kb, t);
const vp = toolVendorProduct(kb, t);
const dup = existing.has(candidateKey(vp.vendor, vp.product));
const facts = [
t.annualSpend !== null ? `${money(t.annualSpend)}/yr` : "",
t.contractEnd ? `contract ends ${t.contractEnd}` : "",
].filter(Boolean).join(" · ");
return (
<li key={t.key}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(t.key)} onChange={() => toggle(t.key)} />
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-2">
<span className="text-[13px] font-medium">{t.label}</span>
{!entry && <span className="chip">Typed in</span>}
{dup && <span className="chip" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>Already in assessment</span>}
{facts && <span className="tabnum text-[12px] text-[var(--text-secondary)]">{facts}</span>}
</span>
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
{dup
? "Fills in spend and contract date on the existing line where they are blank."
: `→ ${entry?.productIds.length ? entry.productIds.map(prodName).join(", ") : "No replacement mapped yet — set it on the line"}`}
</span>
</span>
</label>
</li>
);
})}
</ul>
</div>
))}
{tools.length === 0 && <p className="text-[13px] text-[var(--text-muted)]">No tools left to review.</p>}
</div>
{(noneIn.length > 0 || unsure.length > 0) && (
<div className="mb-4 space-y-2 rounded-lg bg-[var(--surface-2)] p-3 text-[12.5px]">
{noneIn.length > 0 && (
<div>
<span className="font-semibold">Nothing in place:</span>{" "}
<span className="text-[var(--text-secondary)]">{noneIn.map(domainName).join(", ")}. These are whitespace, not displacements.</span>
</div>
)}
{unsure.length > 0 && (
<div>
<span className="font-semibold">Not sure:</span>{" "}
<span className="text-[var(--text-secondary)]">{unsure.map(domainName).join(", ")}. Worth a follow-up question.</span>
</div>
)}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t pt-4">
<span className="mr-auto text-[12px] text-[var(--text-muted)]">Unticked tools are dismissed and won&rsquo;t be offered again.</span>
<button className="btn" onClick={onClose}>Later</button>
<button className="btn btn-primary" disabled={tools.length === 0}
onClick={() => onAccept(accepted, tools.filter((t) => !picked.has(t.key)))}>
Add {accepted.length} tool{accepted.length === 1 ? "" : "s"} to assessment
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
type Mode = "light" | "dark" | "system";
export default function ThemeToggle() {
const [mode, setMode] = useState<Mode>("system");
useEffect(() => {
setMode(((localStorage.getItem("prt-theme") as Mode) || "system"));
}, []);
function apply(next: Mode) {
setMode(next);
if (next === "system") {
localStorage.removeItem("prt-theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("prt-theme", next);
document.documentElement.setAttribute("data-theme", next);
}
}
const order: Mode[] = ["system", "light", "dark"];
const icon = { system: "◐", light: "☀", dark: "☾" }[mode];
return (
<button
className="btn btn-ghost btn-sm"
title={`Theme: ${mode}`}
aria-label={`Theme: ${mode}. Click to change.`}
onClick={() => apply(order[(order.indexOf(mode) + 1) % order.length])}
>
<span className="text-[14px] leading-none">{icon}</span>
<span className="hidden sm:inline capitalize">{mode}</span>
</button>
);
}

370
src/components/charts.tsx Normal file
View File

@@ -0,0 +1,370 @@
"use client";
import { useId, useState } from "react";
import { money } from "@/lib/format";
export const SERIES = [
"var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)",
"var(--series-5)", "var(--series-6)", "var(--series-7)", "var(--series-8)",
];
/* ---------------------------------------------------------------- frame */
export function ChartFrame({
title, subtitle, children, table, right,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
/** Every chart ships a table view: it is the relief for low-contrast hues. */
table?: { headers: string[]; rows: (string | number)[][] };
right?: React.ReactNode;
}) {
const [showTable, setShowTable] = useState(false);
return (
<section className="card avoid-break p-5">
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0">
<h3 className="text-[14px] font-semibold tracking-tight">{title}</h3>
{subtitle && <p className="mt-0.5 text-[12.5px] text-[var(--text-muted)]">{subtitle}</p>}
</div>
<div className="ml-auto flex items-center gap-1.5">
{right}
{table && (
<button className="btn btn-ghost btn-sm no-print" onClick={() => setShowTable((v) => !v)}>
{showTable ? "Chart" : "Table"}
</button>
)}
</div>
</div>
{showTable && table ? <DataTable {...table} /> : children}
</section>
);
}
export function DataTable({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
return (
<div className="scroll-thin -mx-1 overflow-x-auto px-1">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b">
{headers.map((h, i) => (
<th key={h} className={`pb-2 font-semibold text-[var(--text-secondary)] ${i ? "pl-4 text-right" : "text-left"}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-[var(--grid)] last:border-0">
{r.map((c, j) => (
<td key={j} className={`py-1.5 tabnum ${j ? "pl-4 text-right text-[var(--text-secondary)]" : "font-medium"}`}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ------------------------------------------------------------ stat tile */
export function StatTile({
label, value, sub, tone = "neutral",
}: {
label: string;
value: string;
sub?: string;
tone?: "neutral" | "good" | "accent";
}) {
const color =
tone === "good" ? "var(--success-text)" : tone === "accent" ? "var(--accent)" : "var(--text-primary)";
return (
<div className="card avoid-break p-4">
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className="mt-1.5 text-[26px] font-semibold leading-none tracking-tight" style={{ color }}>{value}</div>
{sub && <div className="mt-1.5 text-[12px] text-[var(--text-secondary)]">{sub}</div>}
</div>
);
}
/* ------------------------------------------------------- horizontal bar */
export type BarDatum = { label: string; value: number; color?: string; note?: string };
export function HBar({ data, formatter = (n: number) => money(n) }: { data: BarDatum[]; formatter?: (n: number) => string }) {
const max = Math.max(1, ...data.map((d) => d.value));
if (!data.length) return <Empty />;
return (
<ul className="flex flex-col gap-3">
{data.map((d) => (
<li key={d.label} className="group">
<div className="mb-1 flex items-baseline gap-3">
<span className="truncate text-[12.5px] font-medium">{d.label}</span>
<span className="ml-auto shrink-0 tabnum text-[12.5px] font-semibold">{formatter(d.value)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--grid)]">
<div
className="h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${Math.max(1.5, (d.value / max) * 100)}%`, background: d.color ?? "var(--series-1)" }}
title={`${d.label}: ${formatter(d.value)}${d.note ? ` — ${d.note}` : ""}`}
/>
</div>
{d.note && <div className="mt-1 text-[11.5px] text-[var(--text-muted)]">{d.note}</div>}
</li>
))}
</ul>
);
}
/* --------------------------------------------------------- quadrant map */
export type QuadPoint = {
id: string; x: number; y: number; size: number;
label: string; sublabel: string; colorIndex: number; groupLabel: string;
};
export function Quadrant({ points, groups }: { points: QuadPoint[]; groups: { label: string; colorIndex: number }[] }) {
const [hover, setHover] = useState<QuadPoint | null>(null);
const uid = useId();
const W = 640, H = 420, PAD = { t: 18, r: 18, b: 44, l: 52 };
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
// Axes are fixed 1–5 rating scales, so the domain never moves with the data.
const sx = (v: number) => PAD.l + ((v - 0.5) / 5) * iw;
const sy = (v: number) => PAD.t + ih - ((v - 0.5) / 5) * ih;
const maxSize = Math.max(1, ...points.map((p) => p.size));
const r = (v: number) => 7 + 20 * Math.sqrt(Math.max(0, v) / maxSize);
// Scores are integers 1-5, so candidates routinely land on the same point.
// Fan co-located bubbles out around their shared coordinate so none is hidden.
const placed = (() => {
const groups = new Map<string, QuadPoint[]>();
for (const p of points) {
const k = `${p.x}|${p.y}`;
(groups.get(k) ?? groups.set(k, []).get(k)!).push(p);
}
const out: (QuadPoint & { ox: number; oy: number })[] = [];
for (const group of groups.values()) {
if (group.length === 1) {
out.push({ ...group[0], ox: 0, oy: 0 });
continue;
}
// Largest bubble sits in the centre; the rest ring it, biggest first.
const sorted = [...group].sort((a, b) => b.size - a.size);
const spread = Math.max(...sorted.map((p) => r(p.size))) * 0.95;
sorted.forEach((p, i) => {
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
out.push({ ...p, ox: i === 0 ? 0 : Math.cos(angle) * spread, oy: i === 0 ? 0 : Math.sin(angle) * spread });
});
}
return out;
})();
if (!points.length) return <Empty />;
return (
<div className="relative">
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Capability parity versus switching ease">
<rect x={sx(3)} y={PAD.t} width={PAD.l + iw - sx(3)} height={sy(3) - PAD.t} fill="var(--accent)" opacity=".05" />
<text x={PAD.l + iw - 8} y={PAD.t + ih - 8} textAnchor="end" fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
ACT NOW
</text>
<text x={PAD.l + 8} y={PAD.t + ih - 8} fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
DEFER
</text>
{[1, 2, 3, 4, 5].map((v) => (
<g key={v}>
<line x1={sx(v)} y1={PAD.t} x2={sx(v)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
<text x={sx(v)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
</g>
))}
<line x1={PAD.l} y1={PAD.t + ih} x2={PAD.l + iw} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
<line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
<text x={PAD.l + iw / 2} y={H - 8} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)">
Ease of switching →
</text>
<text x={14} y={PAD.t + ih / 2} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)"
transform={`rotate(-90 14 ${PAD.t + ih / 2})`}>
Capability parity →
</text>
{[...placed].sort((a, b) => b.size - a.size).map((p) => {
const on = hover?.id === p.id;
const cx = sx(p.x) + p.ox, cy = sy(p.y) + p.oy;
return (
<g key={p.id} onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} style={{ cursor: "pointer" }}>
{/* 2px surface ring keeps overlapping bubbles legible */}
<circle cx={cx} cy={cy} r={r(p.size) + 1.5} fill="none" stroke="var(--surface-1)" strokeWidth="2" opacity=".85" />
<circle
cx={cx} cy={cy} r={r(p.size)}
fill={SERIES[p.colorIndex]} fillOpacity={on ? 0.55 : 0.34}
stroke={SERIES[p.colorIndex]} strokeWidth={on ? 2.5 : 2}
/>
</g>
);
})}
{/*
The largest lines carry a visible label: hover is unavailable in print.
Only the lead bubble of each cluster is labelled, or co-located points
would stack their labels on top of one another.
*/}
{[...placed]
.filter((p) => p.ox === 0 && p.oy === 0)
.sort((a, b) => b.size - a.size)
.slice(0, 4)
.map((p) => (
<text
key={`lbl-${p.id}`}
x={sx(p.x) + p.ox}
y={sy(p.y) + p.oy - r(p.size) - 6}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="var(--text-secondary)"
>
{p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label}
</text>
))}
<desc id={uid}>Bubble area is annual spend at stake.</desc>
</svg>
{hover && (
<div className="pointer-events-none absolute left-1/2 top-2 z-10 w-[min(300px,90%)] -translate-x-1/2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-3 text-[12.5px] shadow-[var(--shadow)]">
<div className="flex items-center gap-2 font-semibold">
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: SERIES[hover.colorIndex] }} />
<span className="truncate">{hover.label}</span>
</div>
<div className="mt-1 text-[var(--text-secondary)]">{hover.sublabel}</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 tabnum text-[var(--text-muted)]">
<span>Parity {hover.y}/5</span>
<span>Switch ease {hover.x}/5</span>
<span>{money(hover.size, true)} at stake</span>
</div>
</div>
)}
<Legend groups={groups} note="Bubble size = annual spend at stake" />
</div>
);
}
export function Legend({ groups, note }: { groups: { label: string; colorIndex: number }[]; note?: string }) {
return (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-[var(--text-secondary)]">
{groups.map((g) => (
<span key={g.label} className="inline-flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: SERIES[g.colorIndex] }} />
{g.label}
</span>
))}
{note && <span className="ml-auto text-[var(--text-muted)]">{note}</span>}
</div>
);
}
/* ----------------------------------------------------- cumulative value */
export function PaybackCurve({
annualSavings, migrationCost, years,
}: { annualSavings: number; migrationCost: number; years: number }) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const W = 640, H = 300, PAD = { t: 16, r: 16, b: 40, l: 72 };
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
const steps = Math.max(1, years) * 4; // quarterly resolution
const pts = Array.from({ length: steps + 1 }, (_, i) => {
const yr = (i / steps) * years;
return { yr, net: annualSavings * yr - migrationCost, invested: migrationCost };
});
const lo = Math.min(0, ...pts.map((p) => p.net));
const hi = Math.max(1, ...pts.map((p) => p.net), migrationCost);
const sx = (yr: number) => PAD.l + (yr / Math.max(years, 0.001)) * iw;
const sy = (v: number) => PAD.t + ih - ((v - lo) / (hi - lo || 1)) * ih;
const path = pts.map((p, i) => `${i ? "L" : "M"}${sx(p.yr).toFixed(1)},${sy(p.net).toFixed(1)}`).join(" ");
const area = `${path} L${sx(years)},${sy(0)} L${sx(0)},${sy(0)} Z`;
const breakYr = annualSavings > 0 ? migrationCost / annualSavings : null;
const h = hoverIdx === null ? null : pts[hoverIdx];
return (
<div className="relative">
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Cumulative net benefit over the planning horizon">
<defs>
<linearGradient id="pc-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--series-1)" stopOpacity=".28" />
<stop offset="100%" stopColor="var(--series-1)" stopOpacity="0" />
</linearGradient>
</defs>
{Array.from({ length: years + 1 }, (_, i) => (
<g key={i}>
<line x1={sx(i)} y1={PAD.t} x2={sx(i)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
<text x={sx(i)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)">
{i === 0 ? "Start" : `Yr ${i}`}
</text>
</g>
))}
{[lo, (lo + hi) / 2, hi].map((v, i) => (
<g key={i}>
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{money(v, true)}</text>
</g>
))}
<line x1={PAD.l} y1={sy(0)} x2={PAD.l + iw} y2={sy(0)} stroke="var(--axis)" strokeWidth="1.5" />
<path d={area} fill="url(#pc-fill)" />
<path d={path} fill="none" stroke="var(--series-1)" strokeWidth="2" strokeLinecap="round" />
{breakYr !== null && breakYr <= years && (
<g>
<line x1={sx(breakYr)} y1={PAD.t} x2={sx(breakYr)} y2={PAD.t + ih} stroke="var(--success-text)" strokeWidth="1.5" strokeDasharray="4 3" />
<circle cx={sx(breakYr)} cy={sy(0)} r="4.5" fill="var(--success-text)" stroke="var(--surface-1)" strokeWidth="2" />
<text x={sx(breakYr) + 7} y={PAD.t + 14} fontSize="11" fontWeight="600" fill="var(--success-text)">
Breakeven {breakYr < 1 ? `${Math.round(breakYr * 12)} mo` : `${breakYr.toFixed(1)} yr`}
</text>
</g>
)}
<text x={sx(years) - 4} y={sy(pts[pts.length - 1].net) - 10} textAnchor="end" fontSize="12" fontWeight="600" fill="var(--text-primary)" className="tabnum">
{money(pts[pts.length - 1].net, true)}
</text>
{h && (
<g>
<line x1={sx(h.yr)} y1={PAD.t} x2={sx(h.yr)} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1" />
<circle cx={sx(h.yr)} cy={sy(h.net)} r="4.5" fill="var(--series-1)" stroke="var(--surface-1)" strokeWidth="2" />
</g>
)}
<rect
x={PAD.l} y={PAD.t} width={iw} height={ih} fill="transparent"
onMouseLeave={() => setHoverIdx(null)}
onMouseMove={(e) => {
const box = (e.target as SVGRectElement).getBoundingClientRect();
const frac = (e.clientX - box.left) / box.width;
setHoverIdx(Math.round(Math.min(1, Math.max(0, frac)) * steps));
}}
/>
</svg>
{h && (
<div className="pointer-events-none absolute right-4 top-3 rounded-lg border border-[var(--border-strong)] bg-[var(--surface-1)] px-3 py-2 text-[12.5px] shadow-[var(--shadow)]">
<div className="font-semibold tabnum">{h.yr < 1 ? `Month ${Math.round(h.yr * 12)}` : `Year ${h.yr.toFixed(2)}`}</div>
<div className="tabnum text-[var(--text-secondary)]">Net benefit {money(h.net)}</div>
</div>
)}
</div>
);
}
export function Empty({ children = "Nothing to show yet." }: { children?: React.ReactNode }) {
return (
<div className="flex min-h-[120px] items-center justify-center rounded-xl border border-dashed text-[13px] text-[var(--text-muted)]">
{children}
</div>
);
}

64
src/lib/client.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Fetch helpers for the client pages.
*
* Every page load depends on these, so they must fail loudly rather than
* resolve to undefined: a silent rejection leaves a page stuck on "Loading…".
* An empty body is treated as an error, which is what a dev-server route that
* is still compiling returns on its first hit.
*/
export class HttpError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
async function parse<T>(res: Response, url: string): Promise<T> {
const body = await res.text();
if (!res.ok) {
let detail = "";
try { detail = (JSON.parse(body) as { error?: string }).error ?? ""; } catch { /* not JSON */ }
throw new HttpError(detail || `${res.status} ${res.statusText} from ${url}`, res.status);
}
if (!body.trim()) throw new HttpError(`Empty response from ${url}`, res.status);
try {
return JSON.parse(body) as T;
} catch {
throw new HttpError(`Malformed JSON from ${url}`, res.status);
}
}
/** GET with one retry, since a cold dev-server route can drop the first hit. */
export async function getJson<T>(url: string, retries = 1): Promise<T> {
try {
return await parse<T>(await fetch(url, { cache: "no-store" }), url);
} catch (err) {
// A 404 is a real answer, not a transient failure — never retry it.
if (retries > 0 && !(err instanceof HttpError && err.status === 404)) {
await new Promise((r) => setTimeout(r, 250));
return getJson<T>(url, retries - 1);
}
throw err;
}
}
export async function sendJson<T>(url: string, method: "POST" | "PUT" | "DELETE", body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return parse<T>(res, url);
}
export function errorMessage(err: unknown) {
if (err instanceof HttpError) return err.message;
if (err instanceof Error) return err.message;
return "Something went wrong.";
}

322
src/lib/dossier.ts Normal file
View File

@@ -0,0 +1,322 @@
import type {
AccountIntel, Catalog, IntelApp, IntelConsumption, IntelFact,
IntelImportResult, IntelProposal, KbEntry, KnowledgeBase,
} from "./types";
import { splitVendor } from "./kb";
/* ------------------------------------------------------------------ parsing */
const BULLET = /^[\s•·▪◦*\-–—]+/;
const clean = (s: string) => s.replace(BULLET, "").replace(/\s+/g, " ").trim();
/** Headings the dossier uses. Matching is loose so wording drift does not break it. */
const SECTIONS: { id: string; test: RegExp }[] = [
{ id: "summary", test: /^executive summary$/i },
{ id: "scores", test: /^composite scores$/i },
{ id: "profile", test: /^public company profile$/i },
{ id: "vcf", test: /^(vcf|vcf (status|plan|footprint).*)$/i },
{ id: "infra", test: /^infrastructure( &| and)? cloud strategy$/i },
{ id: "consumption", test: /^product consumption$/i },
{ id: "products", test: /^products$/i },
{ id: "apps", test: /^applications?$/i },
{ id: "workstreams", test: /^workstreams$/i },
{ id: "narrative", test: /^(tech debt signals|cloud strategy assessment|cloud strategic partnerships|cloud repatriation signals|devops platform assessment|ai initiatives|security posture|modernization signals)$/i },
];
/** `Name (4)` marks an application category. */
const APP_CATEGORY = /^([A-Za-z][A-Za-z0-9 &/+._-]{1,40})\s*\((\d+)\)$/;
/** `VSPHERE: Entitled 35768, Deployed 33400, Adoption High` */
const CONSUMPTION = /^(.+?):\s*(?:Entitled\s+([\d,]+))?[,\s]*(?:Deployed\s+([\d,]+))?[,\s]*(?:Adoption\s+([A-Za-z ]+))?$/i;
/**
* Infrastructure lines name one vendor per layer, so each carries the capability
* domains it could plausibly displace. Without this gate a compute vendor like
* "HPE" matches unrelated entries that merely start with the same word.
*/
const INFRA_KEYS: { key: string; domains: string[] }[] = [
{ key: "Compute Vendor", domains: ["compute", "private-cloud"] },
{ key: "Hypervisor", domains: ["compute", "private-cloud"] },
{ key: "Storage Platform", domains: ["storage"] },
{ key: "Storage Vendor", domains: ["storage"] },
{ key: "Network Vendor", domains: ["networking", "adc"] },
{ key: "Firewall Vendor", domains: ["network-sec"] },
{ key: "Load Balancer Vendor", domains: ["adc"] },
{ key: "Backup Vendor", domains: ["resiliency", "storage"] },
{ key: "Database Vendor", domains: ["data"] },
{ key: "Observability Vendor", domains: ["observability"] },
{ key: "Container Platform", domains: ["app-platform"] },
];
const CLOUD_KEYS = ["Cloud Strategy", "Primary Hyperscaler", "Workload Distribution"];
const num = (s: string | undefined) => (s ? Number(s.replace(/,/g, "")) : 0);
export function parseDossier(raw: string, sourceUrl = ""): AccountIntel {
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
const kv = new Map<string, string>(); // every "Key: value" seen
const sectionKv = new Map<string, Map<string, string>>();
const apps: IntelApp[] = [];
const consumption: IntelConsumption[] = [];
const narratives: { heading: string; body: string }[] = [];
let section = "";
let appCategory = "";
let narrativeHeading = "";
let narrativeBody: string[] = [];
const flushNarrative = () => {
if (narrativeHeading && narrativeBody.length) {
narratives.push({ heading: narrativeHeading, body: narrativeBody.join(" ").trim() });
}
narrativeHeading = "";
narrativeBody = [];
};
const title = clean(lines.find((l) => clean(l).length > 0) ?? "");
for (const rawLine of lines) {
const line = clean(rawLine);
if (!line) continue;
const heading = SECTIONS.find((s) => s.test.test(line));
if (heading) {
flushNarrative();
section = heading.id;
appCategory = "";
if (heading.id === "narrative") narrativeHeading = line;
continue;
}
// A short, colon-free line inside the narrative run starts a new narrative block.
if (section === "narrative" && !line.includes(":") && line.length < 60 && /^[A-Z]/.test(line)) {
flushNarrative();
narrativeHeading = line;
continue;
}
if (section === "narrative") { narrativeBody.push(line); continue; }
if (section === "apps") {
const cat = APP_CATEGORY.exec(line);
if (cat) { appCategory = cat[1].trim(); continue; }
if (appCategory && !line.includes(":")) { apps.push({ category: appCategory, name: line }); continue; }
}
if (section === "products" || section === "consumption") {
const m = CONSUMPTION.exec(line);
if (m && /entitled|deployed|adoption/i.test(line)) {
consumption.push({
product: m[1].trim(),
entitled: num(m[2]),
deployed: num(m[3]),
adoption: (m[4] ?? "").trim(),
});
continue;
}
}
const idx = line.indexOf(":");
if (idx > 0 && idx < 60) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key && value) {
if (!kv.has(key)) kv.set(key, value);
if (!sectionKv.has(section)) sectionKv.set(section, new Map());
const sm = sectionKv.get(section)!;
if (!sm.has(key)) sm.set(key, value);
}
}
}
flushNarrative();
const get = (...keys: string[]) => {
for (const k of keys) {
const v = kv.get(k);
if (v && !/^not available$/i.test(v)) return v;
}
return "";
};
const pick = (keys: string[]): IntelFact[] =>
keys.map((k) => ({ label: k, value: kv.get(k) ?? "" })).filter((f) => f.value && !/^not available$/i.test(f.value));
const vcfKeys = [...kv.keys()].filter((k) => /^(vcf|cores|deployment model|adoption stage)/i.test(k));
return {
sourceUrl,
sourceTitle: title,
importedAt: new Date().toISOString(),
company: get("Company"),
industry: get("Industry"),
classification: get("Classification"),
accountStatus: get("Account Status"),
sentiment: get("Account Sentiment", "Sentiment"),
renewalTimeframe: get("Renewal Timeframe"),
renewalRisk: get("Renewal Risk"),
healthScore: get("Account Health Score"),
intelligenceScore: get("Intelligence Score"),
tamValueScore: get("TAM Value Score"),
vcf: pick(vcfKeys),
infrastructure: pick(INFRA_KEYS.map((k) => k.key)),
cloud: pick(CLOUD_KEYS),
applications: apps,
consumption,
deploymentRate: get("Overall Deployment Rate"),
narratives,
};
}
/* ---------------------------------------------------------------- proposals */
/** Values that describe a situation rather than a product worth matching. */
const NOT_A_PRODUCT = /^(not available|none|n\/a|unknown|tbd|various|multiple|no .*|standard|hybrid.*|other)$/i;
/** Splits a name into comparable lowercase tokens. */
const tokens = (s: string) => s.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean);
/**
* Scores one candidate name against the dossier value.
*
* Matching is token-aligned rather than raw substring: "HPE" must be a whole
* token of the entry, so it cannot bleed into an unrelated longer name, and a
* two-character vendor like "F5" still matches exactly.
*/
function scoreName(name: string, q: string): number {
const n = name.toLowerCase().trim();
if (n === q) return 100;
const nt = tokens(n);
const qt = tokens(q);
if (!nt.length || !qt.length) return 0;
// The query is the entry's leading words ("F5" vs "F5 BIG-IP LTM"), or vice versa.
const leads = (a: string[], b: string[]) => b.length >= a.length && a.every((w, i) => w === b[i]);
if (leads(qt, nt) || leads(nt, qt)) return 85;
const shared = qt.filter((w) => nt.includes(w));
if (!shared.length) return 0;
// Every query token present somewhere in the entry name.
if (shared.length === qt.length) return 70;
// A single distinctive shared token is weak evidence on its own.
return shared.some((w) => w.length >= 4) ? 55 : 0;
}
function matchKb(
kb: KnowledgeBase,
needle: string,
enabledIds: Set<string>,
opts: { domains?: string[]; minScore?: number } = {},
): { entry: KbEntry; confidence: IntelProposal["confidence"] } | null {
const q = needle.trim().toLowerCase();
if (!q || q.length < 2 || NOT_A_PRODUCT.test(q)) return null;
const minScore = opts.minScore ?? 70;
let best: { entry: KbEntry; score: number } | null = null;
for (const e of kb.entries) {
// Situations and status-quo entries are never proposed automatically.
if (e.competitive === false) continue;
// A named infrastructure layer can only be displaced by that layer's tools.
if (opts.domains?.length && e.domain && !opts.domains.includes(e.domain)) continue;
const names = [e.label, [e.vendor, e.product].filter(Boolean).join(" "), ...e.aliases].filter(Boolean);
let score = Math.max(0, ...names.map((n) => scoreName(n, q)));
// The vendor name alone only counts when that is the whole query — an
// infrastructure line like "Cisco". Otherwise every Oracle entry would
// match every Oracle product.
// An infrastructure line naming only the vendor is strong evidence the
// vendor is present; which of its products is the uncertain part.
if (e.vendor && q === e.vendor.toLowerCase()) score = Math.max(score, 92);
if (!score) continue;
const enabled = e.productIds.filter((id) => enabledIds.has(id)).length;
score += Math.min(8, e.observations.length * 3);
// An entry whose only mappings are disabled products cannot be actioned,
// so a sibling entry with a live mapping should win.
if (enabled) score += 4;
else if (e.productIds.length) score -= 15;
if (!best || score > best.score) best = { entry: e, score };
}
if (!best || best.score < minScore) return null;
return {
entry: best.entry,
confidence: best.score >= 95 ? "high" : best.score >= 80 ? "medium" : "low",
};
}
function toProposal(key: string, evidence: string, m: { entry: KbEntry; confidence: IntelProposal["confidence"] }): IntelProposal {
const e = m.entry;
const { vendor, product } = e.vendor || e.product ? { vendor: e.vendor, product: e.product } : splitVendor(e.label);
return {
key, evidence,
label: e.label, vendor, product,
domain: e.domain,
productIds: [...e.productIds],
kbEntryId: e.id,
confidence: m.confidence,
capability: e.capability,
switchEase: e.switchEase,
timeToRoiMonths: e.timeToRoiMonths,
savingsPct: e.savingsPct,
migrationCost: e.migrationCost,
risks: e.risks,
};
}
export function buildImportResult(
raw: string, sourceUrl: string, kb: KnowledgeBase, catalog: Catalog,
): IntelImportResult {
const intel = parseDossier(raw, sourceUrl);
const warnings: string[] = [];
const proposals: IntelProposal[] = [];
const seen = new Set<string>();
const enabledIds = new Set(catalog.products.filter((p) => p.enabled).map((p) => p.id));
const consider = (evidence: string, needle: string, opts?: { domains?: string[]; minScore?: number }) => {
const m = matchKb(kb, needle, enabledIds, opts);
if (!m) return;
if (seen.has(m.entry.id)) return;
seen.add(m.entry.id);
proposals.push(toProposal(m.entry.id, evidence, m));
};
// Infrastructure vendors are the highest-signal source: one named vendor per layer.
for (const f of intel.infrastructure) {
const domains = INFRA_KEYS.find((k) => k.key === f.label)?.domains;
const evidence = `${f.label}: ${f.value}`;
// "Everpure (Pure)" — try the bare name and the parenthetical.
const inner = /\(([^)]+)\)/.exec(f.value)?.[1];
consider(evidence, f.value.replace(/\s*\([^)]*\)/, ""), { domains });
if (inner) consider(evidence, inner, { domains });
}
// Application names are noisy, so they must clear a higher bar than a vendor line.
for (const app of intel.applications) {
const evidence = `${app.category}: ${app.name}`;
consider(evidence, app.name.replace(/\s*\([^)]*\)/, ""), { minScore: 84 });
for (const part of app.name.split(/\s*\/\s*/)) {
consider(evidence, part.replace(/\s*\([^)]*\)/, ""), { minScore: 84 });
}
}
if (!intel.company) warnings.push("No “Company:” line found — check the whole dossier was pasted.");
if (!intel.infrastructure.length) warnings.push("No infrastructure vendors found; only application matches were proposed.");
if (!proposals.length) warnings.push("Nothing matched the knowledge base. Add the tools manually and they will be learned.");
// Never propose a product that is switched off in the catalog.
for (const p of proposals) p.productIds = p.productIds.filter((id) => enabledIds.has(id));
const shelfware = intel.consumption.filter((c) => c.entitled > 0 && c.deployed === 0);
proposals.sort((a, b) => {
const rank = { high: 0, medium: 1, low: 2 };
return rank[a.confidence] - rank[b.confidence] || a.label.localeCompare(b.label);
});
return { intel, proposals, shelfware, warnings };
}

42
src/lib/format.ts Normal file
View File

@@ -0,0 +1,42 @@
export function money(n: number, compact = false) {
if (!isFinite(n)) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: compact ? "compact" : "standard",
maximumFractionDigits: compact ? 1 : 0,
}).format(n);
}
export function num(n: number) {
return new Intl.NumberFormat("en-US").format(Math.round(n));
}
export function monthsLabel(m: number | null) {
if (m === null) return "n/a";
if (m <= 0) return "Immediate";
if (m < 12) return `${m.toFixed(m < 3 ? 1 : 0)} months`;
return `${(m / 12).toFixed(1)} years`;
}
export function dateLabel(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
return isNaN(+d) ? iso : d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
export const SCORE_LABEL: Record<number, string> = { 1: "Very low", 2: "Low", 3: "Moderate", 4: "High", 5: "Very high" };
export const CAPABILITY_LABEL: Record<number, string> = {
1: "Major gaps",
2: "Partial overlap",
3: "Most capabilities",
4: "Full parity",
5: "Parity plus upside",
};
export const SWITCH_LABEL: Record<number, string> = {
1: "Very hard — deep integration",
2: "Hard — significant rework",
3: "Moderate — planned project",
4: "Easy — mostly config",
5: "Trivial — drop-in",
};

243
src/lib/kb.ts Normal file
View File

@@ -0,0 +1,243 @@
import type { Assessment, Candidate, KbEntry, KbObservation, KnowledgeBase } from "./types";
/**
* Catalog `replaces` entries that name a status quo or an internal option
* rather than a competitor we could displace.
*/
const NON_COMPETITIVE = [
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
];
export function isCompetitive(label: string) {
return !NON_COMPETITIVE.some((re) => re.test(label));
}
export const KB_DEFAULTS = {
capability: 4,
switchEase: 3,
timeToRoiMonths: 12,
savingsPct: 50,
migrationCost: 0,
};
/** Vendors whose names run to more than one word, so the split does not cut them short. */
const MULTIWORD_VENDORS = [
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
"Amazon Web Services", "Google Cloud", "Scale Computing", "Data Theorem", "Wiz Runtime",
];
export function splitVendor(label: string): { vendor: string; product: string } {
const clean = label.replace(/\s+/g, " ").trim();
for (const v of MULTIWORD_VENDORS) {
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) {
return { vendor: v, product: clean.slice(v.length).trim() };
}
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
}
const parts = clean.split(" ");
if (parts.length === 1) return { vendor: clean, product: "" };
return { vendor: parts[0], product: parts.slice(1).join(" ") };
}
export function kbId(label: string) {
return (
label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 72) || "entry"
);
}
/** The key an assessment candidate matches against. */
export function candidateLabel(c: Pick<Candidate, "incumbentVendor" | "incumbentProduct">) {
return [c.incumbentVendor, c.incumbentProduct].map((s) => s.trim()).filter(Boolean).join(" ");
}
const median = (xs: number[]) => {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
/**
* Recomputes an entry's defaults from its observations.
*
* Median rather than latest, so one unusual deal does not distort the entry;
* product mapping takes the ids that show up in at least half of the uses.
*/
export function recomputeDefaults(entry: KbEntry): KbEntry {
if (entry.pinned) return entry;
// With no observations left, the entry is seeded again — so it must show the
// seed baseline rather than keep numbers learned from a deal that is gone.
// The product mapping is kept: that came from the catalog, not from a deal.
if (entry.observations.length === 0) return { ...entry, ...KB_DEFAULTS };
const obs = entry.observations;
const counts = new Map<string, number>();
for (const o of obs) for (const id of new Set(o.productIds)) counts.set(id, (counts.get(id) ?? 0) + 1);
const majority = [...counts.entries()]
.filter(([, n]) => n * 2 >= obs.length)
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
return {
...entry,
productIds: majority.length ? majority : entry.productIds,
capability: Math.round(median(obs.map((o) => o.capability))),
switchEase: Math.round(median(obs.map((o) => o.switchEase))),
timeToRoiMonths: Math.round(median(obs.map((o) => o.timeToRoiMonths))),
savingsPct: Math.round(median(obs.map((o) => o.savingsPct))),
migrationCost: Math.round(median(obs.map((o) => o.migrationCost))),
};
}
export function newEntry(label: string, origin: KbEntry["origin"]): KbEntry {
const { vendor, product } = splitVendor(label);
const now = new Date().toISOString();
return {
id: kbId(label),
label,
vendor,
product,
aliases: [],
domain: "",
productIds: [],
...KB_DEFAULTS,
unitLabel: "",
risks: "",
notes: "",
competitive: isCompetitive(label),
pinned: false,
origin,
observations: [],
createdAt: now,
updatedAt: now,
};
}
/**
* Folds an assessment's candidates into the knowledge base.
*
* Observations are keyed by assessment + candidate, so re-saving an assessment
* updates that candidate's observation in place instead of stacking duplicates.
* Returns null when nothing changed, so an unchanged save writes no file.
*/
export function learnFromAssessment(kb: KnowledgeBase, a: Assessment): KnowledgeBase | null {
const before = JSON.stringify(kb.entries);
const byId = new Map(kb.entries.map((e) => [e.id, e]));
const now = new Date().toISOString();
for (const c of a.candidates) {
const label = candidateLabel(c);
// Only capture entries a seller has actually filled in and mapped.
if (!label || c.productIds.length === 0) continue;
const id = kbId(label);
let entry = byId.get(id);
if (!entry) {
entry = newEntry(label, "learned");
entry.domain = c.domain;
byId.set(id, entry);
}
const observation: KbObservation = {
sourceKey: `${a.id}::${c.id}`,
assessmentId: a.id,
customerName: a.customerName,
annualSpend: c.annualSpend,
units: c.units,
capability: c.capability,
switchEase: c.switchEase,
timeToRoiMonths: c.timeToRoiMonths,
savingsPct: c.savingsPct,
migrationCost: c.migrationCost,
productIds: [...c.productIds],
recordedAt: now,
};
entry.observations = [
...entry.observations.filter((o) => o.sourceKey !== observation.sourceKey),
observation,
];
if (!entry.domain) entry.domain = c.domain;
if (!entry.unitLabel && c.unitLabel) entry.unitLabel = c.unitLabel;
if (!entry.risks && c.risks.trim()) entry.risks = c.risks.trim();
const next = recomputeDefaults(entry);
next.updatedAt = now;
byId.set(id, next);
}
// Candidates that were removed or renamed must not leave stale observations
// behind, or the medians keep reflecting rows the seller has since deleted.
const liveKeys = new Set(
a.candidates
.filter((c) => candidateLabel(c) && c.productIds.length > 0)
.map((c) => `${a.id}::${c.id}`),
);
for (const [id, entry] of byId) {
const kept = entry.observations.filter((o) => o.assessmentId !== a.id || liveKeys.has(o.sourceKey));
if (kept.length === entry.observations.length) continue;
const next = recomputeDefaults({ ...entry, observations: kept });
next.updatedAt = now;
byId.set(id, next);
}
const entries = [...byId.values()].sort((x, y) => x.label.localeCompare(y.label));
if (JSON.stringify(entries) === before) return null;
return { ...kb, entries, updatedAt: now };
}
/** Drops every observation contributed by an assessment that no longer exists. */
export function forgetAssessment(kb: KnowledgeBase, assessmentId: string): KnowledgeBase | null {
const now = new Date().toISOString();
let changed = false;
const entries = kb.entries.map((entry) => {
const kept = entry.observations.filter((o) => o.assessmentId !== assessmentId);
if (kept.length === entry.observations.length) return entry;
changed = true;
return { ...recomputeDefaults({ ...entry, observations: kept }), updatedAt: now };
});
return changed ? { ...kb, entries, updatedAt: now } : null;
}
/** Ranks knowledge-base entries against what the seller has typed so far. */
export function searchKb(kb: KnowledgeBase, query: string, limit = 8): KbEntry[] {
const q = query.trim().toLowerCase();
if (!q) {
return [...kb.entries].sort((a, b) => b.observations.length - a.observations.length).slice(0, limit);
}
return kb.entries
.map((e) => {
const hay = [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase();
let score = 0;
if (hay.startsWith(q)) score += 100;
else if (e.label.toLowerCase().includes(q)) score += 60;
else if (hay.includes(q)) score += 30;
else return { e, score: 0 };
// Entries proven in real deals outrank ones only seeded from the catalog.
score += Math.min(20, e.observations.length * 4);
return { e, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || a.e.label.localeCompare(b.e.label))
.slice(0, limit)
.map((x) => x.e);
}

17
src/lib/redact.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { Assessment } from "./types";
/**
* Strips everything internal from an assessment.
*
* The account dossier mixes customer-safe facts with pricing strategy,
* competitive risk and personal details, and candidate `notes` are explicitly
* the seller's private workings. Neither belongs in a customer deliverable, so
* they are removed at the boundary rather than merely hidden in the view.
*/
export function customerSafe(a: Assessment): Assessment {
return {
...a,
accountIntel: null,
candidates: a.candidates.map((c) => ({ ...c, notes: "" })),
};
}

196
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,196 @@
import type { Assessment, Candidate, Catalog, Weights } from "./types";
export const DEFAULT_WEIGHTS: Weights = {
capability: 35,
switchEase: 25,
timeToRoi: 25,
spend: 15,
};
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/** ROI speed as a 0–1 score: <=6 months is ideal, >=36 months scores zero. */
export function roiNorm(months: number) {
return clamp01(1 - (months - 6) / 30);
}
export type CandidateMetrics = {
annualSavings: number;
horizonGross: number;
horizonNet: number;
paybackMonths: number | null;
roiPct: number | null;
score: number;
wave: 1 | 2 | 3;
};
export type Rollup = {
metrics: Map<string, CandidateMetrics>;
included: Candidate[];
totalAnnualSpend: number;
addressableSpend: number;
totalAnnualSavings: number;
totalMigrationCost: number;
horizonGross: number;
horizonNet: number;
blendedPaybackMonths: number | null;
savingsPct: number;
avgScore: number;
toolsRetired: number;
vendorsRetired: number;
/** Tools the customer runs after consolidation: our distinct products plus anything left unmapped. */
toolsAfter: number;
/** Vendors after consolidation: us (if anything is mapped) plus the vendors of unmapped tools. */
vendorsAfter: number;
unmappedCount: number;
byWave: Record<1 | 2 | 3, Candidate[]>;
byBu: BuValue[];
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number }[];
};
export type BuValue = {
buId: string; name: string; short: string; color: string;
savings: number; spend: number; migrationCost: number; horizonNet: number;
/** Incumbent lines this BU absorbs (a line mapped to two BUs counts for both). */
count: number;
productIds: string[];
};
export function scoreCandidate(
c: Candidate,
weights: Weights,
maxSpend: number,
horizonYears: number,
): CandidateMetrics {
const cap = clamp01((c.capability - 1) / 4);
const ease = clamp01((c.switchEase - 1) / 4);
const roi = roiNorm(c.timeToRoiMonths);
// Square-root scaling so a single very large line item does not swamp the rest.
const spendN = maxSpend > 0 ? clamp01(Math.sqrt(Math.max(0, c.annualSpend)) / Math.sqrt(maxSpend)) : 0;
const w = weights;
const wSum = w.capability + w.switchEase + w.timeToRoi + w.spend || 1;
const score =
(100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum;
const annualSavings = (c.annualSpend * c.savingsPct) / 100;
const horizonGross = annualSavings * horizonYears;
const horizonNet = horizonGross - c.migrationCost;
const paybackMonths =
annualSavings > 0 ? (c.migrationCost / annualSavings) * 12 : c.migrationCost > 0 ? null : 0;
const roiPct = c.migrationCost > 0 ? (horizonNet / c.migrationCost) * 100 : null;
const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3;
return { annualSavings, horizonGross, horizonNet, paybackMonths, roiPct, score, wave };
}
export function rollup(a: Assessment, catalog: Catalog): Rollup {
const included = a.candidates.filter((c) => c.included);
const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend));
const metrics = new Map<string, CandidateMetrics>();
for (const c of a.candidates) {
metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears));
}
const totalAnnualSpend = a.candidates.reduce((s, c) => s + c.annualSpend, 0);
const addressableSpend = included.reduce((s, c) => s + c.annualSpend, 0);
const totalAnnualSavings = included.reduce((s, c) => s + metrics.get(c.id)!.annualSavings, 0);
const totalMigrationCost = included.reduce((s, c) => s + c.migrationCost, 0);
const horizonGross = totalAnnualSavings * a.horizonYears;
const horizonNet = horizonGross - totalMigrationCost;
const blendedPaybackMonths =
totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null;
const avgScore = included.length
? included.reduce((s, c) => s + metrics.get(c.id)!.score, 0) / included.length
: 0;
const byWave: Record<1 | 2 | 3, Candidate[]> = { 1: [], 2: [], 3: [] };
for (const c of included) byWave[metrics.get(c.id)!.wave].push(c);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
// Savings are split evenly across the mapped products so BU credit sums to the total.
const buAgg = new Map<string, { savings: number; spend: number; migrationCost: number; count: number; products: Set<string> }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const bus = [...new Set(c.productIds.map((id) => prodById.get(id)?.bu).filter(Boolean))] as string[];
if (!bus.length) continue;
for (const bu of bus) {
const e = buAgg.get(bu) ?? { savings: 0, spend: 0, migrationCost: 0, count: 0, products: new Set<string>() };
e.savings += m.annualSavings / bus.length;
e.spend += c.annualSpend / bus.length;
e.migrationCost += c.migrationCost / bus.length;
e.count += 1;
for (const id of c.productIds) if (prodById.get(id)?.bu === bu) e.products.add(id);
buAgg.set(bu, e);
}
}
const byBu: BuValue[] = catalog.businessUnits
.filter((b) => buAgg.has(b.id))
.map((b) => {
const e = buAgg.get(b.id)!;
return {
buId: b.id, name: b.name, short: b.short, color: b.color,
savings: e.savings, spend: e.spend, migrationCost: e.migrationCost,
horizonNet: e.savings * a.horizonYears - e.migrationCost,
count: e.count, productIds: [...e.products],
};
})
.sort((x, y) => y.savings - x.savings);
const mapped = included.filter((c) => c.productIds.length);
const unmapped = included.filter((c) => !c.productIds.length);
const vendorKey = (c: Candidate) => c.incumbentVendor.trim().toLowerCase();
const toolsAfter = new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length;
const vendorsAfter = (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size;
const domAgg = new Map<string, { savings: number; spend: number; count: number }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 };
e.savings += m.annualSavings;
e.spend += c.annualSpend;
e.count += 1;
domAgg.set(c.domain, e);
}
const byDomain = catalog.domains
.filter((d) => domAgg.has(d.id))
.map((d) => ({ domainId: d.id, name: d.name, ...domAgg.get(d.id)! }))
.sort((x, y) => y.savings - x.savings);
return {
metrics,
included,
totalAnnualSpend,
addressableSpend,
totalAnnualSavings,
totalMigrationCost,
horizonGross,
horizonNet,
blendedPaybackMonths,
savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0,
avgScore,
toolsRetired: included.length,
vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size,
toolsAfter,
vendorsAfter,
unmappedCount: unmapped.length,
byWave,
byDomain,
byBu,
};
}
export const WAVE_LABEL: Record<1 | 2 | 3, string> = {
1: "Wave 1 — Quick wins",
2: "Wave 2 — Planned consolidation",
3: "Wave 3 — Strategic / longer horizon",
};
export const WAVE_BLURB: Record<1 | 2 | 3, string> = {
1: "Strong functional parity, low switching friction and fast payback. Start here.",
2: "Clear consolidation value with a manageable migration. Schedule against contract renewals.",
3: "Material savings but higher effort, lower parity or a longer payback. Sequence last or revisit scope.",
};

238
src/lib/store.ts Normal file
View File

@@ -0,0 +1,238 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
import { forgetAssessment, learnFromAssessment } from "./kb";
import { isSurveyToken } from "./survey";
const DATA_DIR = path.join(process.cwd(), "data");
const CATALOG_PATH = path.join(DATA_DIR, "catalog.json");
const KB_PATH = path.join(DATA_DIR, "knowledge-base.json");
const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments");
const SURVEY_DIR = path.join(DATA_DIR, "surveys");
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await fs.readFile(file, "utf8")) as T;
}
async function writeJson(file: string, value: unknown) {
await fs.mkdir(path.dirname(file), { recursive: true });
// Write-then-rename so a crash mid-write can never truncate a saved assessment.
const tmp = `${file}.${process.pid}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
await fs.rename(tmp, file);
}
export async function getCatalog(): Promise<Catalog> {
return readJson<Catalog>(CATALOG_PATH);
}
export async function saveCatalog(c: Catalog): Promise<Catalog> {
c.updatedAt = new Date().toISOString().slice(0, 10);
await writeJson(CATALOG_PATH, c);
return c;
}
export async function getKnowledgeBase(): Promise<KnowledgeBase> {
try {
return await readJson<KnowledgeBase>(KB_PATH);
} catch {
return { version: 1, updatedAt: new Date().toISOString(), entries: [] };
}
}
export async function saveKnowledgeBase(kb: KnowledgeBase): Promise<KnowledgeBase> {
kb.updatedAt = new Date().toISOString();
await writeJson(KB_PATH, kb);
return kb;
}
/** Assessment ids become filenames, so they must not escape the assessments dir. */
export function isSafeId(id: string) {
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(id);
}
function assessmentPath(id: string) {
if (!isSafeId(id)) throw new Error(`Invalid assessment id: ${id}`);
return path.join(ASSESSMENT_DIR, `${id}.json`);
}
export async function listAssessments(): Promise<AssessmentSummary[]> {
await fs.mkdir(ASSESSMENT_DIR, { recursive: true });
const files = (await fs.readdir(ASSESSMENT_DIR)).filter((f) => f.endsWith(".json"));
const catalog = await getCatalog();
const out: AssessmentSummary[] = [];
for (const f of files) {
try {
const a = await readJson<Assessment>(path.join(ASSESSMENT_DIR, f));
const r = rollup(a, catalog);
out.push({
id: a.id,
customerName: a.customerName,
preparedBy: a.preparedBy,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
candidateCount: a.candidates.length,
annualSpend: r.addressableSpend,
annualSavings: r.totalAnnualSavings,
});
} catch {
// Skip unreadable or hand-edited files rather than failing the whole list.
}
}
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getAssessment(id: string): Promise<Assessment | null> {
try {
return await readJson<Assessment>(assessmentPath(id));
} catch {
return null;
}
}
export async function saveAssessment(a: Assessment): Promise<Assessment> {
a.updatedAt = new Date().toISOString();
await writeJson(assessmentPath(a.id), a);
// Every save folds what the seller entered back into the knowledge base, so
// the next assessment starts from real numbers. A failure here must never
// lose the assessment itself, which is already on disk.
try {
const kb = await getKnowledgeBase();
const next = learnFromAssessment(kb, a);
if (next) await saveKnowledgeBase(next);
} catch (err) {
console.error("Knowledge-base learning failed for", a.id, err);
}
return a;
}
export async function deleteAssessment(id: string) {
await fs.rm(assessmentPath(id), { force: true });
for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true });
try {
const kb = await getKnowledgeBase();
const next = forgetAssessment(kb, id);
if (next) await saveKnowledgeBase(next);
} catch (err) {
console.error("Knowledge-base cleanup failed for", id, err);
}
}
export function slugify(s: string) {
const base = s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return base || "assessment";
}
export async function createAssessment(input: Partial<Assessment>): Promise<Assessment> {
await fs.mkdir(ASSESSMENT_DIR, { recursive: true });
const customerName = (input.customerName || "New Customer").trim();
const base = slugify(customerName);
let id = base;
let n = 2;
while (await getAssessment(id)) id = `${base}-${n++}`;
const now = new Date().toISOString();
const a: Assessment = {
id,
version: 1,
customerName,
industry: input.industry ?? "",
preparedBy: input.preparedBy ?? "",
preparedFor: input.preparedFor ?? "",
opportunityRef: input.opportunityRef ?? "",
executiveSummary: input.executiveSummary ?? "",
horizonYears: input.horizonYears ?? 3,
weights: input.weights ?? { ...DEFAULT_WEIGHTS },
candidates: input.candidates ?? [],
createdAt: now,
updatedAt: now,
};
await saveAssessment(a);
return a;
}
/* ------------------------------------------------------------ questionnaires */
function surveyPath(id: string) {
if (!isSurveyToken(id)) throw new Error("Invalid questionnaire id");
return path.join(SURVEY_DIR, `${id}.json`);
}
export async function getSurvey(id: string): Promise<Survey | null> {
if (!isSurveyToken(id)) return null;
try {
return await readJson<Survey>(surveyPath(id));
} catch {
return null;
}
}
export async function listSurveys(assessmentId: string): Promise<Survey[]> {
await fs.mkdir(SURVEY_DIR, { recursive: true });
const out: Survey[] = [];
for (const f of await fs.readdir(SURVEY_DIR)) {
if (!f.endsWith(".json")) continue;
try {
const s = await readJson<Survey>(path.join(SURVEY_DIR, f));
if (s.assessmentId === assessmentId) out.push(s);
} catch {
// A half-written or hand-edited file should not hide the others.
}
}
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function createSurvey(assessmentId: string, respondentName: string, domains: string[]): Promise<Survey> {
const now = new Date().toISOString();
const s: Survey = {
id: crypto.randomBytes(16).toString("hex"),
assessmentId,
respondentName,
domains,
answers: {},
status: "open",
review: {},
createdAt: now,
updatedAt: now,
submittedAt: "",
};
await writeJson(surveyPath(s.id), s);
return s;
}
// The respondent and the seller both write the same file (answers vs. review
// marks), so every change is a read-modify-write serialised per questionnaire.
const surveyLocks = new Map<string, Promise<unknown>>();
export async function mutateSurvey(id: string, fn: (s: Survey) => void | string): Promise<Survey | string | null> {
const prev = surveyLocks.get(id) ?? Promise.resolve();
const run = prev.catch(() => {}).then(async () => {
const s = await getSurvey(id);
if (!s) return null;
const refusal = fn(s);
if (typeof refusal === "string") return refusal;
s.updatedAt = new Date().toISOString();
await writeJson(surveyPath(id), s);
return s;
});
surveyLocks.set(id, run);
try {
return await run;
} finally {
if (surveyLocks.get(id) === run) surveyLocks.delete(id);
}
}
export async function deleteSurvey(id: string) {
await fs.rm(surveyPath(id), { force: true });
}

104
src/lib/survey.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { Candidate, Catalog, KnowledgeBase, Survey, SurveyAnswer, SurveyTool } from "./types";
/** Share tokens: long enough that a link cannot be guessed, and safe as a filename. */
export function isSurveyToken(id: string) {
return /^[a-f0-9]{32}$/.test(id);
}
/** Only categories we actually sell into are worth asking about. */
export function surveyableDomains(catalog: Catalog) {
const live = new Set(catalog.products.filter((p) => p.enabled).map((p) => p.domain));
return catalog.domains.filter((d) => live.has(d.id));
}
/** Most-used competitors first, so the likely answers are the visible chips. */
export function domainOptions(kb: KnowledgeBase, domain: string) {
return kb.entries
.filter((e) => e.domain === domain && e.competitive)
.sort((a, b) => b.observations.length - a.observations.length || a.label.localeCompare(b.label))
.map((e) => ({ id: e.id, label: e.label }));
}
export function answeredCount(s: Pick<Survey, "domains" | "answers">) {
return s.domains.filter((d) => s.answers[d]?.status).length;
}
export type PendingTool = SurveyTool & { domain: string };
export function surveyTools(s: Survey): PendingTool[] {
return s.domains.flatMap((d) => {
const a = s.answers[d];
return a?.status === "tools" ? a.tools.map((t) => ({ ...t, domain: d })) : [];
});
}
/** Tools the seller has not accepted or dismissed yet. Only submitted surveys feed the inbox. */
export function pendingTools(s: Survey): PendingTool[] {
return s.status === "submitted" ? surveyTools(s).filter((t) => !s.review[t.key]) : [];
}
/** "2027-03" → "2027-03-31": a contract that ends in a month runs to its last day. */
export function monthEnd(ym: string) {
const m = /^(\d{4})-(\d{2})$/.exec(ym);
if (!m) return "";
const last = new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate();
return `${ym}-${String(last).padStart(2, "0")}`;
}
/** Accepts what an executive would type: "$1.2m", "250k", "90,000". */
export function parseSpend(input: string): number | null {
const s = input.trim().toLowerCase().replace(/[$,\s]/g, "");
if (!s) return null;
const m = /^(\d+(?:\.\d+)?)([km]|mm|b)?$/.exec(s);
if (!m) return null;
const mult = { k: 1e3, m: 1e6, mm: 1e6, b: 1e9 }[m[2] as "k" | "m" | "mm" | "b"] ?? 1;
return Math.round(parseFloat(m[1]) * mult);
}
/* -------------------------------------------------------------- sanitising */
const str = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
/**
* The respondent endpoint is reachable by anyone holding the link, so the
* answers it accepts are rebuilt field by field rather than trusted.
*/
export function sanitizeAnswers(domains: string[], raw: unknown): Record<string, SurveyAnswer> {
const out: Record<string, SurveyAnswer> = {};
const src = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
for (const d of domains) {
const a = src[d] as Partial<SurveyAnswer> | undefined;
if (!a || typeof a !== "object") continue;
const status = (["", "tools", "none", "unsure"] as const).find((x) => x === a.status) ?? "";
const seen = new Set<string>();
const tools: SurveyTool[] = [];
for (const t of Array.isArray(a.tools) ? a.tools.slice(0, 25) : []) {
const key = str(t?.key, 40);
const label = str(t?.label, 120);
if (!key || !label || seen.has(key)) continue;
seen.add(key);
const spend = typeof t.annualSpend === "number" && isFinite(t.annualSpend) && t.annualSpend >= 0
? Math.min(Math.round(t.annualSpend), 1e11) : null;
const end = str(t.contractEnd, 7);
tools.push({
key, label,
kbEntryId: str(t.kbEntryId, 80),
annualSpend: spend,
contractEnd: /^\d{4}-(0[1-9]|1[0-2])$/.test(end) ? end : "",
});
}
out[d] = { status, tools: status === "tools" ? tools : [] };
}
return out;
}
/* ---------------------------------------------------------- review → candidates */
export function candidateKey(vendor: string, product: string) {
return `${vendor} ${product}`.trim().toLowerCase();
}
/** Fields a questionnaire answer contributes to a new or existing candidate. */
export function toolFacts(t: SurveyTool): Pick<Candidate, "annualSpend" | "renewalDate"> {
return { annualSpend: t.annualSpend ?? 0, renewalDate: monthEnd(t.contractEnd) };
}

170
src/lib/timeline.ts Normal file
View File

@@ -0,0 +1,170 @@
import type { Assessment, Catalog } from "./types";
import type { Rollup } from "./scoring";
/**
* Implementation plan: turns the scored candidates into a dated project
* sequence. Positions are in whole months from the plan start (month 0 is the
* first full month after today), and a project occupies [start, end).
*
* Rules, in order:
* 1. A tool with a known contract end is scheduled to cut over by that date,
* so the customer does not renew it. If there is no longer time to do so,
* it starts now and is flagged as needing a short bridge extension.
* 2. Everything else is sequenced into the earliest month its workstream has
* capacity: lines with a known saving before those without (spend not yet
* captured), then by wave (quick wins first), then by size of saving. Each
* business unit is its own workstream (security, infrastructure and ops
* migrations are run by different teams), with a set number in parallel.
* 3. Savings start at cutover, or at contract end if that comes later —
* the customer keeps paying for a contract until it expires.
*/
/** Migration length in months, by ease-of-switching score (5 = trivial). */
export const DURATION_BY_EASE: Record<number, number> = { 5: 2, 4: 3, 3: 5, 2: 8, 1: 12 };
/** Default migrations each workstream runs at once. */
export const DEFAULT_PARALLEL = 2;
/** Earliest month each wave may start. */
const WAVE_OFFSET: Record<1 | 2 | 3, number> = { 1: 0, 2: 3, 3: 6 };
export type PlanItem = {
id: string;
label: string;
products: string;
color: string;
bu: string;
wave: 1 | 2 | 3;
start: number;
end: number;
/** Month the contract runs out (the bar position is the end of that month), null if unknown. */
contractEnd: number | null;
savingsFrom: number;
annualSavings: number;
migrationCost: number;
anchor: "contract" | "sequenced";
/** Contract ends before the migration can finish. */
bridge: boolean;
};
export type Plan = {
/** ISO date of month 0 (first of the month). */
startIso: string;
items: PlanItem[];
/** Month the last project completes. */
completion: number;
/** Month full run-rate savings are reached. */
fullRunRate: number;
/** Annual run-rate savings in effect during each month, 0..span-1. */
ramp: number[];
span: number;
/** Savings actually realised inside the planning horizon once ramp-up is counted. */
realizedInHorizon: number;
/** The same horizon if every saving started on day one — the flat model the headline uses. */
flatInHorizon: number;
bridgeCount: number;
unmappedCount: number;
};
const monthIndex = (start: Date, iso: string) => {
const d = new Date(`${iso}T00:00:00Z`);
if (isNaN(+d)) return null;
return (d.getUTCFullYear() - start.getUTCFullYear()) * 12 + (d.getUTCMonth() - start.getUTCMonth());
};
export function monthDate(startIso: string, n: number) {
const d = new Date(`${startIso}T00:00:00Z`);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
}
export function monthLabel(startIso: string, n: number, long = false) {
return monthDate(startIso, n).toLocaleDateString("en-US", { month: long ? "long" : "short", year: "numeric", timeZone: "UTC" });
}
export function quarterLabel(startIso: string, n: number) {
const d = monthDate(startIso, n);
return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`;
}
export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = new Date()): Plan {
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
const startIso = start.toISOString().slice(0, 10);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
const buById = new Map(catalog.businessUnits.map((b) => [b.id, b]));
const mapped = r.included.filter((c) => c.productIds.length);
const parallel = Math.max(1, Math.round(a.planParallel ?? DEFAULT_PARALLEL));
const load = new Map<string, number[]>();
const track = (ws: string) => { if (!load.has(ws)) load.set(ws, []); return load.get(ws)!; };
const occupy = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) l[m] = (l[m] ?? 0) + 1; };
const fits = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) if ((l[m] ?? 0) >= parallel) return false; return true; };
const items: (PlanItem & { duration: number; workstream: string })[] = [];
const base = (c: (typeof mapped)[number]) => {
const m = r.metrics.get(c.id)!;
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
return {
id: c.id,
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
color: bu?.color ?? "var(--text-muted)",
bu: bu?.short ?? "",
workstream: bu?.id ?? "other",
wave: m.wave,
annualSavings: m.annualSavings,
migrationCost: c.migrationCost,
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
};
};
// 1. Contract-anchored work is fixed first; it is dictated by the customer's paper, not our preference.
const flexible: typeof mapped = [];
for (const c of mapped) {
const ce = c.renewalDate ? monthIndex(start, c.renewalDate) : null;
if (ce === null) { flexible.push(c); continue; }
const b = base(c);
const due = ce + 1; // cut over by the end of the contract month
const bridge = due - b.duration < 0;
const s = bridge ? 0 : due - b.duration;
const e = s + b.duration;
occupy(b.workstream, s, e);
items.push({
...b, start: s, end: e, contractEnd: ce, anchor: "contract", bridge,
savingsFrom: Math.max(e, due),
});
}
// 2. The rest is sequenced into capacity: money first, then quick wins, then the biggest lines.
flexible.sort((x, y) => {
const mx = r.metrics.get(x.id)!, my = r.metrics.get(y.id)!;
return Number(my.annualSavings > 0) - Number(mx.annualSavings > 0)
|| mx.wave - my.wave || my.annualSavings - mx.annualSavings || my.score - mx.score;
});
for (const c of flexible) {
const b = base(c);
let s = WAVE_OFFSET[b.wave];
while (!fits(b.workstream, s, s + b.duration) && s < 240) s++;
occupy(b.workstream, s, s + b.duration);
items.push({ ...b, start: s, end: s + b.duration, contractEnd: null, anchor: "sequenced", bridge: false, savingsFrom: s + b.duration });
}
items.sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
const completion = Math.max(0, ...items.map((i) => i.end));
const fullRunRate = Math.max(0, ...items.map((i) => i.savingsFrom));
const horizon = a.horizonYears * 12;
const span = Math.max(completion, fullRunRate, 1) + 2;
const ramp = Array.from({ length: Math.max(span, horizon) }, (_, m) =>
items.reduce((s, i) => s + (m >= i.savingsFrom ? i.annualSavings : 0), 0));
const realizedInHorizon = ramp.slice(0, horizon).reduce((s, v) => s + v / 12, 0);
const flatInHorizon = items.reduce((s, i) => s + i.annualSavings, 0) * a.horizonYears;
// duration and workstream were only needed for scheduling.
const clean: PlanItem[] = items.map(({ duration: _d, workstream: _w, ...rest }) => rest);
return {
startIso, items: clean, completion, fullRunRate, ramp: ramp.slice(0, span), span,
realizedInHorizon, flatInHorizon,
bridgeCount: clean.filter((i) => i.bridge).length,
unmappedCount: r.included.length - mapped.length,
};
}

266
src/lib/types.ts Normal file
View File

@@ -0,0 +1,266 @@
export type BusinessUnit = { id: string; name: string; short: string; color: string };
export type Domain = {
id: string;
name: string;
/** Plain-language, vendor-neutral line shown to questionnaire respondents. */
description?: string;
};
export type Product = {
id: string;
name: string;
bu: string;
domain: string;
description: string;
replaces: string[];
url: string;
enabled: boolean;
notes?: string;
};
export type Catalog = {
version: number;
updatedAt: string;
source: string[];
businessUnits: BusinessUnit[];
domains: Domain[];
products: Product[];
};
/** How the three scored dimensions are combined into a rationalization score. */
export type Weights = {
capability: number; // comparability of our product vs. the incumbent
switchEase: number; // how hard the migration is
timeToRoi: number; // how fast the customer sees payback
spend: number; // how much incumbent spend is at stake
};
export type Candidate = {
id: string;
/** Incumbent / competitive tool being displaced. */
incumbentVendor: string;
incumbentProduct: string;
domain: string;
/** Catalog product ids proposed as the replacement. */
productIds: string[];
annualSpend: number; // customer's current annual cost, USD
units: number; // seats / cores / MSU — free-form quantity
unitLabel: string;
renewalDate: string; // ISO yyyy-mm-dd, "" if unknown
/** 1–5 scores. */
capability: number; // 5 = full functional parity or better
switchEase: number; // 5 = trivial to switch
timeToRoiMonths: number; // months until cumulative benefit exceeds cost
/** Share of incumbent spend eliminated by consolidating, 0–100. */
savingsPct: number;
/** One-off migration cost, USD. */
migrationCost: number;
strategicValue: number; // 1–5, non-financial importance to the customer
risks: string;
notes: string;
/** Excluded from rollups and the customer report without being deleted. */
included: boolean;
};
export type Assessment = {
id: string;
version: number;
customerName: string;
industry: string;
preparedBy: string;
preparedFor: string;
opportunityRef: string;
executiveSummary: string;
horizonYears: number;
weights: Weights;
candidates: Candidate[];
/** Implementation plan: migrations each business-unit workstream runs in parallel (default 2). */
planParallel?: number;
/** Imported account dossier. Internal context; never rendered to the customer. */
accountIntel?: AccountIntel | null;
createdAt: string;
updatedAt: string;
};
export type AssessmentSummary = Pick<
Assessment,
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt"
> & { candidateCount: number; annualSpend: number; annualSavings: number };
/* ------------------------------------------------- competitive knowledge base */
/** One recorded use of a knowledge-base entry in a real assessment. */
export type KbObservation = {
/** `${assessmentId}::${candidateId}` — lets a re-save replace this observation in place. */
sourceKey: string;
assessmentId: string;
customerName: string;
annualSpend: number;
units: number;
capability: number;
switchEase: number;
timeToRoiMonths: number;
savingsPct: number;
migrationCost: number;
productIds: string[];
recordedAt: string;
};
export type KbEntry = {
id: string;
/** Full competitor string as a seller would say it, e.g. "F5 BIG-IP LTM". */
label: string;
vendor: string;
product: string;
aliases: string[];
domain: string;
/** Defaults applied when this entry is picked in an assessment. */
productIds: string[];
capability: number;
switchEase: number;
timeToRoiMonths: number;
savingsPct: number;
migrationCost: number;
unitLabel: string;
risks: string;
notes: string;
/**
* False for entries that describe a situation rather than a purchasable rival
* ("Manual reviews", "Physical tape (status quo)"). They stay searchable by
* hand but are never proposed automatically from a dossier.
*/
competitive: boolean;
/** Pinned entries are curated: auto-learning never overwrites their defaults. */
pinned: boolean;
origin: "catalog" | "learned" | "manual";
observations: KbObservation[];
createdAt: string;
updatedAt: string;
};
export type KnowledgeBase = {
version: number;
updatedAt: string;
entries: KbEntry[];
};
/* ------------------------------------------------------ account intelligence */
export type IntelFact = { label: string; value: string };
export type IntelApp = { category: string; name: string };
export type IntelConsumption = { product: string; entitled: number; deployed: number; adoption: string };
/**
* Parsed account dossier. Internal by default: the source document mixes
* customer-safe facts with internal pricing strategy and personal data, so
* nothing here reaches the customer report unless a seller promotes it.
*/
export type AccountIntel = {
sourceUrl: string;
sourceTitle: string;
importedAt: string;
company: string;
industry: string;
classification: string;
accountStatus: string;
sentiment: string;
renewalTimeframe: string;
renewalRisk: string;
healthScore: string;
intelligenceScore: string;
tamValueScore: string;
vcf: IntelFact[];
infrastructure: IntelFact[];
cloud: IntelFact[];
applications: IntelApp[];
consumption: IntelConsumption[];
deploymentRate: string;
narratives: { heading: string; body: string }[];
};
/** A candidate the importer suggests; nothing is written until a seller accepts. */
export type IntelProposal = {
key: string;
/** What the dossier said, e.g. "Load Balancer Vendor: F5". */
evidence: string;
label: string;
vendor: string;
product: string;
domain: string;
productIds: string[];
kbEntryId: string;
confidence: "high" | "medium" | "low";
capability: number;
switchEase: number;
timeToRoiMonths: number;
savingsPct: number;
migrationCost: number;
risks: string;
};
export type IntelImportResult = {
intel: AccountIntel;
proposals: IntelProposal[];
/** Entitled but undeployed products — expansion plays, not displacements. */
shelfware: IntelConsumption[];
warnings: string[];
};
/* ------------------------------------------------------ landscape questionnaire */
/** One competitive tool a respondent says is in place. */
export type SurveyTool = {
/** Stable per-answer key; the review inbox tracks accept/dismiss by it. */
key: string;
label: string;
/** Knowledge-base entry the respondent picked, "" for a typed-in tool. */
kbEntryId: string;
annualSpend: number | null;
/** yyyy-mm, "" if unknown. */
contractEnd: string;
};
export type SurveyAnswer = {
/** "" means the respondent has not answered this category yet. */
status: "" | "tools" | "none" | "unsure";
tools: SurveyTool[];
};
/**
* A shareable, token-addressed questionnaire that captures an account's tool
* landscape. Kept outside the assessment file so a respondent's autosave can
* never race the seller's workbench autosave.
*/
export type Survey = {
/** Unguessable token; it is the share link. */
id: string;
assessmentId: string;
respondentName: string;
/** Category (domain) ids, in the order the respondent steps through them. */
domains: string[];
answers: Record<string, SurveyAnswer>;
status: "open" | "submitted";
/** Seller's decision per tool key. Keys missing here are still pending review. */
review: Record<string, "accepted" | "dismissed">;
createdAt: string;
updatedAt: string;
submittedAt: string;
};
/** What the respondent's page receives: no internal data beyond the category list. */
export type PublicSurvey = {
id: string;
customerName: string;
respondentName: string;
status: Survey["status"];
answers: Survey["answers"];
categories: { id: string; name: string; description: string; options: { id: string; label: string }[] }[];
};