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:
187
src/app/api/assessments/[id]/docx/route.ts
Normal file
187
src/app/api/assessments/[id]/docx/route.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
20
src/app/api/assessments/[id]/intel/route.ts
Normal file
20
src/app/api/assessments/[id]/intel/route.ts
Normal 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));
|
||||
}
|
||||
15
src/app/api/assessments/[id]/report/route.ts
Normal file
15
src/app/api/assessments/[id]/report/route.ts
Normal 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 });
|
||||
}
|
||||
23
src/app/api/assessments/[id]/route.ts
Normal file
23
src/app/api/assessments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
42
src/app/api/assessments/[id]/surveys/[token]/route.ts
Normal file
42
src/app/api/assessments/[id]/surveys/[token]/route.ts
Normal 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 });
|
||||
}
|
||||
25
src/app/api/assessments/[id]/surveys/route.ts
Normal file
25
src/app/api/assessments/[id]/surveys/route.ts
Normal 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));
|
||||
}
|
||||
24
src/app/api/assessments/route.ts
Normal file
24
src/app/api/assessments/route.ts
Normal 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));
|
||||
}
|
||||
21
src/app/api/catalog/route.ts
Normal file
21
src/app/api/catalog/route.ts
Normal 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));
|
||||
}
|
||||
21
src/app/api/knowledge-base/route.ts
Normal file
21
src/app/api/knowledge-base/route.ts
Normal 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));
|
||||
}
|
||||
76
src/app/api/q/[token]/route.ts
Normal file
76
src/app/api/q/[token]/route.ts
Normal 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();
|
||||
}
|
||||
435
src/app/assessments/[id]/page.tsx
Normal file
435
src/app/assessments/[id]/page.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
284
src/app/assessments/[id]/report/page.tsx
Normal file
284
src/app/assessments/[id]/report/page.tsx
Normal 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
354
src/app/catalog/page.tsx
Normal 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
223
src/app/globals.css
Normal 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
13
src/app/icon.svg
Normal 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 |
327
src/app/knowledge-base/page.tsx
Normal file
327
src/app/knowledge-base/page.tsx
Normal 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 & 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
25
src/app/layout.tsx
Normal 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
150
src/app/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Assessment, AssessmentSummary } from "@/lib/types";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { Empty } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<AssessmentSummary[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError("");
|
||||
try {
|
||||
setRows(await getJson<AssessmentSummary[]>("/api/assessments"));
|
||||
} catch (e) {
|
||||
setLoadError(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/** Only navigate once the server has confirmed the new id. */
|
||||
async function open(created: Assessment) {
|
||||
if (!created?.id) throw new Error("The server did not return an assessment id.");
|
||||
router.push(`/assessments/${created.id}`);
|
||||
}
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || busy) return;
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
await open(await sendJson<Assessment>("/api/assessments", "POST", { customerName: name.trim() }));
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile(f: File) {
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const parsed = JSON.parse(await f.text());
|
||||
await open(await sendJson<Assessment>("/api/assessments", "POST", { import: parsed }));
|
||||
} catch (e) {
|
||||
setErr(f.name.endsWith(".json") ? errorMessage(e) : `${f.name} is not a JSON assessment file.`);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string, label: string) {
|
||||
if (!confirm(`Delete the assessment for ${label}? The JSON file is removed from data/assessments.`)) return;
|
||||
try {
|
||||
await sendJson(`/api/assessments/${id}`, "DELETE");
|
||||
load();
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const totalSavings = (rows ?? []).reduce((s, r) => s + r.annualSavings, 0);
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-7 flex flex-wrap items-end gap-6">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Assessments</h1>
|
||||
<p className="mt-1 max-w-2xl text-[13.5px] text-[var(--text-secondary)]">
|
||||
Capture a customer’s incumbent tool landscape, map it onto our portfolio, and build the consolidation
|
||||
business case. Every assessment is a portable JSON file under <code className="rounded bg-[var(--surface-2)] px-1 py-0.5 text-[12px]">data/assessments/</code>.
|
||||
</p>
|
||||
</div>
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Pipeline savings identified</div>
|
||||
<div className="tabnum text-[22px] font-semibold" style={{ color: "var(--success-text)" }}>{money(totalSavings)}/yr</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-end gap-3">
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<div className="w-[260px]">
|
||||
<label className="label" htmlFor="new-customer">New assessment</label>
|
||||
<input id="new-customer" className="field" placeholder="Customer name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={!name.trim() || busy}>{busy ? "Working…" : "Create"}</button>
|
||||
</form>
|
||||
<button className="btn" onClick={() => fileRef.current?.click()} disabled={busy}>Import JSON</button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept="application/json" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) importFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<LoadError message={loadError} onRetry={load} />
|
||||
) : rows === null ? (
|
||||
<div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<Empty>No assessments yet — name a customer above to start one.</Empty>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="card group relative p-5 transition-shadow hover:border-[var(--text-muted)]">
|
||||
<Link href={`/assessments/${r.id}`} className="block">
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 className="truncate text-[15.5px] font-semibold tracking-tight">{r.customerName}</h2>
|
||||
<span className="chip ml-auto shrink-0">{r.candidateCount} tools</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend assessed</div>
|
||||
<div className="tabnum text-[16px] font-semibold">{money(r.annualSpend, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Annual saving</div>
|
||||
<div className="tabnum text-[16px] font-semibold" style={{ color: "var(--success-text)" }}>{money(r.annualSavings, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[11.5px] text-[var(--text-muted)]">
|
||||
{r.preparedBy ? `${r.preparedBy} · ` : ""}Updated {dateLabel(r.updatedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<Link className="btn btn-ghost btn-sm" href={`/assessments/${r.id}/report`}>Report</Link>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" onClick={() => remove(r.id, r.customerName)} aria-label={`Delete ${r.customerName}`}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
src/app/q/[token]/page.tsx
Normal file
463
src/app/q/[token]/page.tsx
Normal 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’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’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 & 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>
|
||||
);
|
||||
}
|
||||
130
src/components/AccountIntelStrip.tsx
Normal file
130
src/components/AccountIntelStrip.tsx
Normal 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
306
src/components/Analysis.tsx
Normal 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),
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
228
src/components/CandidateRow.tsx
Normal file
228
src/components/CandidateRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/components/IncumbentPicker.tsx
Normal file
119
src/components/IncumbentPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/IntelImport.tsx
Normal file
198
src/components/IntelImport.tsx
Normal 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"} & 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>
|
||||
);
|
||||
}
|
||||
16
src/components/LoadError.tsx
Normal file
16
src/components/LoadError.tsx
Normal 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
26
src/components/Logo.tsx
Normal 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
56
src/components/Nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/ProductPicker.tsx
Normal file
97
src/components/ProductPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/components/SurveyPanel.tsx
Normal file
168
src/components/SurveyPanel.tsx
Normal 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’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’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>
|
||||
);
|
||||
}
|
||||
147
src/components/SurveyReview.tsx
Normal file
147
src/components/SurveyReview.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
39
src/components/ThemeToggle.tsx
Normal file
39
src/components/ThemeToggle.tsx
Normal 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
370
src/components/charts.tsx
Normal 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
64
src/lib/client.ts
Normal 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
322
src/lib/dossier.ts
Normal 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
42
src/lib/format.ts
Normal 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
243
src/lib/kb.ts
Normal 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
17
src/lib/redact.ts
Normal 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
196
src/lib/scoring.ts
Normal 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
238
src/lib/store.ts
Normal 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
104
src/lib/survey.ts
Normal 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
170
src/lib/timeline.ts
Normal 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
266
src/lib/types.ts
Normal 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 }[] }[];
|
||||
};
|
||||
Reference in New Issue
Block a user