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>
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
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.",
|
||
};
|