import React, { useState, useMemo } from 'react' import { useData } from '../DataContext.jsx' import { fmtCr, fmtL } from '../utils.js' function computeImpacts(grains, dabP, cp) { return grains.map(m => { const ownPct = m.OwnE * (dabP / 100) const compPct = Object.entries(m.CompCoefs || {}).reduce((s, [c, v]) => s + v * ((cp[c] || 0) / 100), 0) const totPct = ownPct + compPct const baseVol = m.BaseVol25 || 0 const pml = m.PricePerMl || 0 const gc = (m.GC || 0) / 100 const baseVal = baseVol * pml * 1000 const newVol = baseVol * (1 + totPct) const newPml = pml * (1 + dabP / 100) const newVal = newVol * newPml * 1000 const volL = newVol - baseVol const valRs = newVal - baseVal const gcRs = newVal * gc - baseVal * gc return { m, totPct, volL, valRs, gcRs, vs: m.ValShare || 0 } }) } // 'DABUR SARSON AMLA' -> 'Dabur Sarson Amla' function toLabel(brand) { return brand.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) } export default function Simulation() { const { models, stats } = useData() const brandLabel = stats?.brand || '' // Derive competitors dynamically from CompCoefs in models data const competitors = useMemo(() => { if (!models) return [] const compSet = new Set() models.forEach(m => Object.keys(m.CompCoefs || {}).forEach(c => compSet.add(c))) return [...compSet].sort() }, [models]) const [fch, setFch] = useState('ALL') const [frg, setFrg] = useState('ALL') const [fpack, setFpack] = useState('ALL') const [dP, setDP] = useState(5) // compResp initialised dynamically once competitors are known const [compResp, setCompResp] = useState({}) // Keep compResp keys in sync with competitors list const syncedCompResp = useMemo(() => { const obj = {} competitors.forEach(c => { obj[c] = compResp[c] ?? 0 }) return obj }, [competitors, compResp]) const grains = useMemo(() => { if (!models) return [] return models.filter(m => (fch === 'ALL' || m.Channel === fch) && (frg === 'ALL' || m.Region === frg) && (fpack === 'ALL' || m.Pack === fpack) ) }, [models, fch, frg, fpack]) const scenarios = useMemo(() => { if (!grains.length) return [] const allZero = Object.fromEntries(competitors.map(c => [c, 0])) const allDP = Object.fromEntries(competitors.map(c => [c, dP])) const allHalf = Object.fromEntries(competitors.map(c => [c, dP / 2])) return [ { num: 1, cls: 'sn1', name: `${brandLabel} only`, desc: 'Competitors hold prices', dabP: dP, cp: allZero }, { num: 2, cls: 'sn2', name: 'All comps match', desc: 'All competitors match %', dabP: dP, cp: allDP }, { num: 3, cls: 'sn3', name: 'Custom response', desc: 'Individual responses', dabP: dP, cp: syncedCompResp }, { num: 4, cls: 'sn4', name: '50% comp response', desc: 'Comps at half focal %', dabP: dP, cp: allHalf }, ].map(sc => { const impacts = computeImpacts(grains, sc.dabP, sc.cp) const vsT = grains.reduce((s, m) => s + (m.ValShare || 0), 0) const wtdPct = vsT > 0 ? impacts.reduce((s, x) => s + x.totPct * (x.vs / vsT), 0) : 0 const totVolL = impacts.reduce((s, x) => s + x.volL, 0) const totValRs = impacts.reduce((s, x) => s + x.valRs, 0) const totGcRs = impacts.reduce((s, x) => s + x.gcRs, 0) return { ...sc, impacts, wtdPct, totVolL, totValRs, totGcRs } }) }, [grains, dP, syncedCompResp, competitors, brandLabel]) if (!models) return
Loading…
return (
Price Simulation
Configure a price change scenario and see volume, value and GC impacts across 4 competitive response cases.
{/* Inputs */}
{/* Grain filters */}
Channel
Region
Pack
{/* Price inputs */}
{brandLabel} Price Change %
setDP(parseFloat(e.target.value) || 0)} style={{ width: 120, padding: '7px 10px', border: '1px solid var(--bd2)', borderRadius: 7, fontSize: 13, fontFamily: 'var(--mono)' }} />
{competitors.length > 0 && (
Competitor Response %
{competitors.map(brand => (
{toLabel(brand)}
setCompResp(prev => ({ ...prev, [brand]: parseFloat(e.target.value) || 0 }))} />
))}
)}
{grains.length} grains matched · GC = vol change × price/ml × 1000 × GC%
{/* Scenario cards */} {grains.length === 0 ?
No grains match selected filters.
: (
{scenarios.map(sc => )}
) }
) } function ScenarioCard({ sc }) { return (
{sc.num}
{sc.name}
{sc.desc}
{/* Per-grain rows */}
{sc.impacts.map((x, i) => (
{x.m.Pack}·{x.m.Channel}·{x.m.Region.slice(0, 4)}
= 0 ? 'var(--gn)' : 'var(--rd)' }}> {x.totPct >= 0 ? '+' : ''}{(x.totPct * 100).toFixed(1)}% {fmtCr(x.valRs)} = 0 ? 'var(--tl)' : '#c07000' }}> GC:{fmtCr(x.gcRs)}
))}
{/* Portfolio summary */}
Portfolio Impact
= 0 ? '+' : '') + (sc.wtdPct * 100).toFixed(1) + '%'} sub={fmtL(sc.totVolL)} color={sc.wtdPct >= 0 ? 'var(--gn)' : 'var(--rd)'} /> = 0 ? 'var(--bl)' : 'var(--rd)'} /> = 0 ? 'var(--tl)' : 'var(--rd)'} />
) } function KpiBox({ label, value, sub, color }) { return (
{label}
{value}
{sub}
) }