Spaces:
Running
Running
| import { useState, useMemo } from "react"; | |
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, Area, AreaChart, ComposedChart, Bar } from "recharts"; | |
| const TOOLS_DEFAULT = [ | |
| { | |
| name: "Comulate", | |
| color: "#E85D26", | |
| monthlyCost: 2500, | |
| setupCost: 5000, | |
| hoursPerWeekSaved: 12, | |
| avgHourlyCost: 45, | |
| }, | |
| { | |
| name: "Fulcrum", | |
| color: "#2D7DD2", | |
| monthlyCost: 1800, | |
| setupCost: 3500, | |
| hoursPerWeekSaved: 8, | |
| avgHourlyCost: 45, | |
| }, | |
| { | |
| name: "Tailwind", | |
| color: "#17B890", | |
| monthlyCost: 1200, | |
| setupCost: 2000, | |
| hoursPerWeekSaved: 6, | |
| avgHourlyCost: 45, | |
| }, | |
| ]; | |
| const MONTHS = 18; | |
| function calcBreakevenMonth(tool) { | |
| const monthlySavings = tool.hoursPerWeekSaved * tool.avgHourlyCost * 4.33; | |
| const netMonthly = monthlySavings - tool.monthlyCost; | |
| if (netMonthly <= 0) return null; | |
| return Math.ceil(tool.setupCost / netMonthly); | |
| } | |
| function generateData(tools, months) { | |
| const data = []; | |
| for (let m = 0; m <= months; m++) { | |
| const point = { month: m }; | |
| let totalCost = 0; | |
| let totalSavings = 0; | |
| let totalHrs = 0; | |
| tools.forEach((t) => { | |
| const cost = t.setupCost + t.monthlyCost * m; | |
| const savings = t.hoursPerWeekSaved * t.avgHourlyCost * 4.33 * m; | |
| point[`${t.name}_cost`] = Math.round(cost); | |
| point[`${t.name}_savings`] = Math.round(savings); | |
| point[`${t.name}_net`] = Math.round(savings - cost); | |
| point[`${t.name}_hrs`] = t.hoursPerWeekSaved; | |
| totalCost += cost; | |
| totalSavings += savings; | |
| totalHrs += t.hoursPerWeekSaved; | |
| }); | |
| point.totalHrs = totalHrs; | |
| point.totalCost = Math.round(totalCost); | |
| point.totalSavings = Math.round(totalSavings); | |
| point.totalNet = Math.round(totalSavings - totalCost); | |
| data.push(point); | |
| } | |
| return data; | |
| } | |
| const fmt = (v) => `$${Math.abs(v).toLocaleString()}`; | |
| const fmtAxis = (v) => { | |
| if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(0)}k`; | |
| return `$${v}`; | |
| }; | |
| const CustomTooltip = ({ active, payload, label }) => { | |
| if (!active || !payload) return null; | |
| return ( | |
| <div style={{ | |
| background: "#1a1a2e", | |
| border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 10, | |
| padding: "12px 16px", | |
| fontFamily: "'DM Sans', sans-serif", | |
| fontSize: 13, | |
| color: "#ccc", | |
| boxShadow: "0 8px 32px rgba(0,0,0,0.4)", | |
| }}> | |
| <div style={{ fontWeight: 700, color: "#fff", marginBottom: 6, fontSize: 14 }}> | |
| Month {label} | |
| </div> | |
| {payload.map((p, i) => ( | |
| <div key={i} style={{ display: "flex", justifyContent: "space-between", gap: 20, marginBottom: 2 }}> | |
| <span style={{ color: p.color }}>● {p.name}</span> | |
| <span style={{ fontWeight: 600, color: "#fff" }}>{fmt(p.value)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| }; | |
| function InputField({ label, value, onChange, prefix = "$", suffix = "" }) { | |
| return ( | |
| <div style={{ marginBottom: 10 }}> | |
| <label style={{ | |
| display: "block", | |
| fontSize: 11, | |
| fontWeight: 500, | |
| color: "rgba(255,255,255,0.45)", | |
| textTransform: "uppercase", | |
| letterSpacing: "0.08em", | |
| marginBottom: 4, | |
| fontFamily: "'DM Sans', sans-serif", | |
| }}> | |
| {label} | |
| </label> | |
| <div style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| background: "rgba(255,255,255,0.05)", | |
| borderRadius: 8, | |
| border: "1px solid rgba(255,255,255,0.08)", | |
| padding: "6px 10px", | |
| }}> | |
| {prefix && <span style={{ color: "rgba(255,255,255,0.3)", fontSize: 13, marginRight: 4 }}>{prefix}</span>} | |
| <input | |
| type="number" | |
| value={value} | |
| onChange={(e) => onChange(Number(e.target.value) || 0)} | |
| style={{ | |
| background: "transparent", | |
| border: "none", | |
| outline: "none", | |
| color: "#fff", | |
| fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", | |
| width: "100%", | |
| fontWeight: 500, | |
| }} | |
| /> | |
| {suffix && <span style={{ color: "rgba(255,255,255,0.3)", fontSize: 12, marginLeft: 4, whiteSpace: "nowrap" }}>{suffix}</span>} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ToolCard({ tool, onChange, breakevenMonth }) { | |
| const monthlySavings = tool.hoursPerWeekSaved * tool.avgHourlyCost * 4.33; | |
| const netMonthly = monthlySavings - tool.monthlyCost; | |
| const isPositive = netMonthly > 0; | |
| return ( | |
| <div style={{ | |
| background: "rgba(255,255,255,0.03)", | |
| borderRadius: 14, | |
| padding: 20, | |
| border: `1px solid ${tool.color}22`, | |
| position: "relative", | |
| overflow: "hidden", | |
| }}> | |
| <div style={{ | |
| position: "absolute", | |
| top: 0, | |
| left: 0, | |
| right: 0, | |
| height: 3, | |
| background: `linear-gradient(90deg, ${tool.color}, ${tool.color}44)`, | |
| }} /> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 16 }}> | |
| <div style={{ | |
| width: 10, | |
| height: 10, | |
| borderRadius: "50%", | |
| background: tool.color, | |
| boxShadow: `0 0 12px ${tool.color}66`, | |
| }} /> | |
| <h3 style={{ | |
| margin: 0, | |
| fontSize: 18, | |
| fontWeight: 700, | |
| color: "#fff", | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| }}> | |
| {tool.name} | |
| </h3> | |
| </div> | |
| <div style={{ | |
| display: "grid", | |
| gridTemplateColumns: "1fr 1fr", | |
| gap: 8, | |
| marginBottom: 14, | |
| }}> | |
| <InputField label="Setup Cost" value={tool.setupCost} onChange={(v) => onChange({ ...tool, setupCost: v })} /> | |
| <InputField label="Monthly Cost" value={tool.monthlyCost} onChange={(v) => onChange({ ...tool, monthlyCost: v })} /> | |
| <InputField label="Hrs Saved/Week" value={tool.hoursPerWeekSaved} prefix="" suffix="hrs" onChange={(v) => onChange({ ...tool, hoursPerWeekSaved: v })} /> | |
| <InputField label="Avg Hourly Cost" value={tool.avgHourlyCost} onChange={(v) => onChange({ ...tool, avgHourlyCost: v })} /> | |
| </div> | |
| <div style={{ | |
| display: "grid", | |
| gridTemplateColumns: "1fr 1fr", | |
| gap: 8, | |
| }}> | |
| <div style={{ | |
| background: "rgba(255,255,255,0.04)", | |
| borderRadius: 10, | |
| padding: "10px 12px", | |
| textAlign: "center", | |
| }}> | |
| <div style={{ fontSize: 11, color: "rgba(255,255,255,0.4)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}> | |
| Net Monthly | |
| </div> | |
| <div style={{ | |
| fontSize: 18, | |
| fontWeight: 700, | |
| fontFamily: "'DM Mono', monospace", | |
| color: isPositive ? "#17B890" : "#E85D26", | |
| }}> | |
| {isPositive ? "+" : "-"}{fmt(netMonthly)} | |
| </div> | |
| </div> | |
| <div style={{ | |
| background: breakevenMonth ? `${tool.color}11` : "rgba(255,255,255,0.04)", | |
| borderRadius: 10, | |
| padding: "10px 12px", | |
| textAlign: "center", | |
| border: breakevenMonth ? `1px solid ${tool.color}33` : "none", | |
| }}> | |
| <div style={{ fontSize: 11, color: "rgba(255,255,255,0.4)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}> | |
| Breakeven | |
| </div> | |
| <div style={{ | |
| fontSize: 18, | |
| fontWeight: 700, | |
| fontFamily: "'DM Mono', monospace", | |
| color: breakevenMonth ? tool.color : "rgba(255,255,255,0.25)", | |
| }}> | |
| {breakevenMonth ? `Month ${breakevenMonth}` : "N/A"} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function BreakevenDashboard() { | |
| const [tools, setTools] = useState(TOOLS_DEFAULT); | |
| const [view, setView] = useState("combined"); | |
| const data = useMemo(() => generateData(tools, MONTHS), [tools]); | |
| const breakevenMonths = useMemo(() => tools.map(calcBreakevenMonth), [tools]); | |
| const combinedBreakeven = useMemo(() => { | |
| const totalSetup = tools.reduce((s, t) => s + t.setupCost, 0); | |
| const totalMonthlyNet = tools.reduce((s, t) => { | |
| const savings = t.hoursPerWeekSaved * t.avgHourlyCost * 4.33; | |
| return s + (savings - t.monthlyCost); | |
| }, 0); | |
| if (totalMonthlyNet <= 0) return null; | |
| return Math.ceil(totalSetup / totalMonthlyNet); | |
| }, [tools]); | |
| const totalAnnualSavings = useMemo(() => { | |
| return tools.reduce((s, t) => { | |
| const monthly = t.hoursPerWeekSaved * t.avgHourlyCost * 4.33 - t.monthlyCost; | |
| return s + monthly * 12; | |
| }, 0) - tools.reduce((s, t) => s + t.setupCost, 0); | |
| }, [tools]); | |
| const totalHoursSaved = tools.reduce((s, t) => s + t.hoursPerWeekSaved, 0); | |
| const totalFTEEquiv = (totalHoursSaved / 40).toFixed(1); | |
| const updateTool = (idx, newTool) => { | |
| const next = [...tools]; | |
| next[idx] = newTool; | |
| setTools(next); | |
| }; | |
| return ( | |
| <div style={{ | |
| minHeight: "100vh", | |
| background: "#0d0d1a", | |
| color: "#fff", | |
| fontFamily: "'DM Sans', sans-serif", | |
| padding: "32px 24px", | |
| }}> | |
| <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700;800&family=DM+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| {/* Header */} | |
| <div style={{ maxWidth: 1200, margin: "0 auto 32px" }}> | |
| <div style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 12, | |
| marginBottom: 8, | |
| }}> | |
| <div style={{ | |
| width: 36, | |
| height: 36, | |
| borderRadius: 10, | |
| background: "linear-gradient(135deg, #E85D26, #2D7DD2, #17B890)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| fontSize: 18, | |
| }}> | |
| ⚡ | |
| </div> | |
| <h1 style={{ | |
| margin: 0, | |
| fontSize: 28, | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| fontWeight: 800, | |
| background: "linear-gradient(135deg, #fff, rgba(255,255,255,0.6))", | |
| WebkitBackgroundClip: "text", | |
| WebkitTextFillColor: "transparent", | |
| }}> | |
| AI Tool ROI — Breakeven Analysis | |
| </h1> | |
| </div> | |
| <p style={{ margin: 0, color: "rgba(255,255,255,0.4)", fontSize: 14 }}> | |
| Adjust the inputs below to model manpower savings across Comulate, Fulcrum & Tailwind | |
| </p> | |
| </div> | |
| {/* Summary Strip */} | |
| <div style={{ | |
| maxWidth: 1200, | |
| margin: "0 auto 28px", | |
| display: "grid", | |
| gridTemplateColumns: "repeat(4, 1fr)", | |
| gap: 12, | |
| }}> | |
| {[ | |
| { label: "Combined Breakeven", value: combinedBreakeven ? `Month ${combinedBreakeven}` : "N/A", accent: "#2D7DD2" }, | |
| { label: "Hours Saved / Week", value: `${totalHoursSaved} hrs`, accent: "#17B890" }, | |
| { label: "FTE Equivalent", value: `${totalFTEEquiv} FTEs`, accent: "#E85D26" }, | |
| { label: "Year 1 Net Savings", value: totalAnnualSavings > 0 ? `+${fmt(totalAnnualSavings)}` : `-${fmt(totalAnnualSavings)}`, accent: totalAnnualSavings > 0 ? "#17B890" : "#E85D26" }, | |
| ].map((s, i) => ( | |
| <div key={i} style={{ | |
| background: "rgba(255,255,255,0.03)", | |
| borderRadius: 12, | |
| padding: "16px 18px", | |
| border: "1px solid rgba(255,255,255,0.06)", | |
| }}> | |
| <div style={{ fontSize: 11, color: "rgba(255,255,255,0.4)", textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}> | |
| {s.label} | |
| </div> | |
| <div style={{ | |
| fontSize: 22, | |
| fontWeight: 700, | |
| fontFamily: "'DM Mono', monospace", | |
| color: s.accent, | |
| }}> | |
| {s.value} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Tool Cards */} | |
| <div style={{ | |
| maxWidth: 1200, | |
| margin: "0 auto 32px", | |
| display: "grid", | |
| gridTemplateColumns: "repeat(3, 1fr)", | |
| gap: 16, | |
| }}> | |
| {tools.map((t, i) => ( | |
| <ToolCard | |
| key={t.name} | |
| tool={t} | |
| onChange={(newT) => updateTool(i, newT)} | |
| breakevenMonth={breakevenMonths[i]} | |
| /> | |
| ))} | |
| </div> | |
| {/* Chart Toggle */} | |
| <div style={{ maxWidth: 1200, margin: "0 auto 16px", display: "flex", gap: 8 }}> | |
| {["combined", "individual"].map((v) => ( | |
| <button | |
| key={v} | |
| onClick={() => setView(v)} | |
| style={{ | |
| background: view === v ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.03)", | |
| border: view === v ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(255,255,255,0.06)", | |
| borderRadius: 8, | |
| padding: "8px 18px", | |
| color: view === v ? "#fff" : "rgba(255,255,255,0.4)", | |
| fontSize: 13, | |
| fontWeight: 600, | |
| cursor: "pointer", | |
| fontFamily: "'DM Sans', sans-serif", | |
| transition: "all 0.2s", | |
| }} | |
| > | |
| {v === "combined" ? "Combined View" : "Per Tool View"} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Charts */} | |
| <div style={{ | |
| maxWidth: 1200, | |
| margin: "0 auto", | |
| background: "rgba(255,255,255,0.02)", | |
| borderRadius: 16, | |
| border: "1px solid rgba(255,255,255,0.06)", | |
| padding: "24px 20px 16px", | |
| }}> | |
| {view === "combined" ? ( | |
| <> | |
| <h3 style={{ | |
| margin: "0 0 16px 8px", | |
| fontSize: 15, | |
| fontWeight: 600, | |
| color: "rgba(255,255,255,0.6)", | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| }}> | |
| Total Investment vs. Total Savings ({MONTHS}-Month Projection) | |
| </h3> | |
| <ResponsiveContainer width="100%" height={380}> | |
| <ComposedChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}> | |
| <defs> | |
| <linearGradient id="savingsGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#17B890" stopOpacity={0.25} /> | |
| <stop offset="100%" stopColor="#17B890" stopOpacity={0.02} /> | |
| </linearGradient> | |
| <linearGradient id="costGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#E85D26" stopOpacity={0.2} /> | |
| <stop offset="100%" stopColor="#E85D26" stopOpacity={0.02} /> | |
| </linearGradient> | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> | |
| <XAxis | |
| dataKey="month" | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={(v) => `M${v}`} | |
| /> | |
| <YAxis | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={fmtAxis} | |
| /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Legend | |
| wrapperStyle={{ paddingTop: 12, fontSize: 13, color: "rgba(255,255,255,0.5)" }} | |
| /> | |
| <Area type="monotone" dataKey="totalSavings" name="Total Savings" fill="url(#savingsGrad)" stroke="#17B890" strokeWidth={2.5} dot={false} /> | |
| <Area type="monotone" dataKey="totalCost" name="Total Cost" fill="url(#costGrad)" stroke="#E85D26" strokeWidth={2.5} dot={false} /> | |
| {combinedBreakeven && combinedBreakeven <= MONTHS && ( | |
| <ReferenceLine | |
| x={combinedBreakeven} | |
| stroke="#FFD166" | |
| strokeDasharray="6 4" | |
| strokeWidth={2} | |
| label={{ | |
| value: `Breakeven → Month ${combinedBreakeven}`, | |
| position: "top", | |
| fill: "#FFD166", | |
| fontSize: 12, | |
| fontWeight: 600, | |
| }} | |
| /> | |
| )} | |
| </ComposedChart> | |
| </ResponsiveContainer> | |
| {/* FTE Hours Chart */} | |
| <div style={{ | |
| marginTop: 32, | |
| paddingTop: 28, | |
| borderTop: "1px solid rgba(255,255,255,0.06)", | |
| }}> | |
| <h3 style={{ | |
| margin: "0 0 6px 8px", | |
| fontSize: 15, | |
| fontWeight: 600, | |
| color: "rgba(255,255,255,0.6)", | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| }}> | |
| Weekly Hours Saved vs. FTE Capacity | |
| </h3> | |
| <p style={{ | |
| margin: "0 0 16px 8px", | |
| fontSize: 12, | |
| color: "rgba(255,255,255,0.3)", | |
| }}> | |
| Stacked hours per tool — dashed lines mark each FTE threshold (40 hrs/week) | |
| </p> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 5, left: 10 }}> | |
| <defs> | |
| {tools.map((t) => ( | |
| <linearGradient key={`grad-${t.name}`} id={`hrsGrad_${t.name}`} x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor={t.color} stopOpacity={0.85} /> | |
| <stop offset="100%" stopColor={t.color} stopOpacity={0.55} /> | |
| </linearGradient> | |
| ))} | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> | |
| <XAxis | |
| dataKey="month" | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={(v) => `M${v}`} | |
| /> | |
| <YAxis | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={(v) => `${v}h`} | |
| domain={[0, (dataMax) => { | |
| const maxFTE = Math.ceil(dataMax / 40) * 40; | |
| return Math.max(maxFTE, 40) + 10; | |
| }]} | |
| /> | |
| <Tooltip | |
| content={({ active, payload, label }) => { | |
| if (!active || !payload) return null; | |
| const totalH = payload.reduce((s, p) => s + (p.value || 0), 0); | |
| const fteCount = (totalH / 40).toFixed(1); | |
| return ( | |
| <div style={{ | |
| background: "#1a1a2e", | |
| border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 10, | |
| padding: "12px 16px", | |
| fontFamily: "'DM Sans', sans-serif", | |
| fontSize: 13, | |
| color: "#ccc", | |
| boxShadow: "0 8px 32px rgba(0,0,0,0.4)", | |
| }}> | |
| <div style={{ fontWeight: 700, color: "#fff", marginBottom: 6, fontSize: 14 }}> | |
| Month {label} | |
| </div> | |
| {payload.map((p, i) => ( | |
| <div key={i} style={{ display: "flex", justifyContent: "space-between", gap: 20, marginBottom: 2 }}> | |
| <span style={{ color: p.color || p.fill }}>● {p.name}</span> | |
| <span style={{ fontWeight: 600, color: "#fff" }}>{p.value} hrs</span> | |
| </div> | |
| ))} | |
| <div style={{ | |
| marginTop: 6, | |
| paddingTop: 6, | |
| borderTop: "1px solid rgba(255,255,255,0.1)", | |
| display: "flex", | |
| justifyContent: "space-between", | |
| gap: 20, | |
| }}> | |
| <span style={{ color: "#FFD166" }}>Total</span> | |
| <span style={{ fontWeight: 700, color: "#FFD166" }}>{totalH} hrs ({fteCount} FTEs)</span> | |
| </div> | |
| </div> | |
| ); | |
| }} | |
| /> | |
| <Legend wrapperStyle={{ paddingTop: 12, fontSize: 13 }} /> | |
| {tools.map((t) => ( | |
| <Bar | |
| key={`bar-${t.name}`} | |
| dataKey={`${t.name}_hrs`} | |
| name={t.name} | |
| stackId="hrs" | |
| fill={`url(#hrsGrad_${t.name})`} | |
| /> | |
| ))} | |
| {(() => { | |
| const maxFTEs = Math.ceil(tools.reduce((s, t) => s + t.hoursPerWeekSaved, 0) / 40); | |
| const lines = []; | |
| for (let f = 1; f <= Math.max(maxFTEs, 1); f++) { | |
| lines.push( | |
| <ReferenceLine | |
| key={`fte-${f}`} | |
| y={f * 40} | |
| stroke="#FFD166" | |
| strokeDasharray="8 5" | |
| strokeWidth={1.5} | |
| strokeOpacity={0.7} | |
| label={{ | |
| value: `${f} FTE (${f * 40}h)`, | |
| position: "right", | |
| fill: "#FFD166", | |
| fontSize: 11, | |
| fontWeight: 600, | |
| }} | |
| /> | |
| ); | |
| } | |
| return lines; | |
| })()} | |
| </ComposedChart> | |
| </ResponsiveContainer> | |
| {/* FTE status callout */} | |
| {(() => { | |
| const total = tools.reduce((s, t) => s + t.hoursPerWeekSaved, 0); | |
| const ftes = Math.floor(total / 40); | |
| const remainder = total % 40; | |
| const pct = ((total / 40) * 100).toFixed(0); | |
| const crossed = total >= 40; | |
| return ( | |
| <div style={{ | |
| marginTop: 16, | |
| padding: "14px 20px", | |
| borderRadius: 12, | |
| background: crossed | |
| ? "linear-gradient(135deg, rgba(23,184,144,0.12), rgba(23,184,144,0.04))" | |
| : "linear-gradient(135deg, rgba(255,209,102,0.1), rgba(255,209,102,0.03))", | |
| border: crossed | |
| ? "1px solid rgba(23,184,144,0.25)" | |
| : "1px solid rgba(255,209,102,0.2)", | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 14, | |
| }}> | |
| <div style={{ fontSize: 28, lineHeight: 1 }}> | |
| {crossed ? "✅" : "⏳"} | |
| </div> | |
| <div> | |
| <div style={{ | |
| fontSize: 15, | |
| fontWeight: 700, | |
| color: crossed ? "#17B890" : "#FFD166", | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| marginBottom: 3, | |
| }}> | |
| {crossed | |
| ? `Crossed ${ftes} FTE${ftes > 1 ? "s" : ""} — ${total} hrs/week saved` | |
| : `${total} hrs/week saved — ${pct}% toward 1 FTE` | |
| } | |
| </div> | |
| <div style={{ fontSize: 13, color: "rgba(255,255,255,0.45)" }}> | |
| {crossed | |
| ? `That's ${ftes} full-time equivalent${ftes > 1 ? "s" : ""} + ${remainder} hrs/week of capacity reclaimed across the 3 tools.` | |
| : `You need ${40 - total} more hrs/week of automation to reach 1 full FTE equivalent.` | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <h3 style={{ | |
| margin: "0 0 16px 8px", | |
| fontSize: 15, | |
| fontWeight: 600, | |
| color: "rgba(255,255,255,0.6)", | |
| fontFamily: "'Bricolage Grotesque', sans-serif", | |
| }}> | |
| Net Cumulative Value Per Tool ({MONTHS}-Month Projection) | |
| </h3> | |
| <ResponsiveContainer width="100%" height={380}> | |
| <ComposedChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> | |
| <XAxis | |
| dataKey="month" | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={(v) => `M${v}`} | |
| /> | |
| <YAxis | |
| stroke="rgba(255,255,255,0.2)" | |
| tick={{ fill: "rgba(255,255,255,0.35)", fontSize: 12 }} | |
| tickFormatter={fmtAxis} | |
| /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Legend wrapperStyle={{ paddingTop: 12, fontSize: 13 }} /> | |
| <ReferenceLine y={0} stroke="rgba(255,255,255,0.15)" strokeWidth={1.5} /> | |
| {tools.map((t, i) => ( | |
| <Line | |
| key={t.name} | |
| type="monotone" | |
| dataKey={`${t.name}_net`} | |
| name={t.name} | |
| stroke={t.color} | |
| strokeWidth={2.5} | |
| dot={false} | |
| activeDot={{ r: 5, stroke: t.color, strokeWidth: 2, fill: "#0d0d1a" }} | |
| /> | |
| ))} | |
| {tools.map((t, i) => | |
| breakevenMonths[i] && breakevenMonths[i] <= MONTHS ? ( | |
| <ReferenceLine | |
| key={`ref-${t.name}`} | |
| x={breakevenMonths[i]} | |
| stroke={t.color} | |
| strokeDasharray="4 4" | |
| strokeOpacity={0.5} | |
| /> | |
| ) : null | |
| )} | |
| </ComposedChart> | |
| </ResponsiveContainer> | |
| </> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div style={{ | |
| maxWidth: 1200, | |
| margin: "24px auto 0", | |
| padding: "14px 18px", | |
| background: "rgba(255,255,255,0.02)", | |
| borderRadius: 10, | |
| border: "1px solid rgba(255,255,255,0.05)", | |
| display: "flex", | |
| justifyContent: "space-between", | |
| alignItems: "center", | |
| fontSize: 12, | |
| color: "rgba(255,255,255,0.3)", | |
| }}> | |
| <span>Monthly savings = (Hours/Week × Hourly Cost × 4.33) − Monthly License Cost</span> | |
| <span>Breakeven = Setup Cost ÷ Net Monthly Savings</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |