Bera
initial deploy
14356bb
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 <div className="panel"><div className="no-data">Loading…</div></div>
return (
<div className="panel">
<div className="stitle">Price Simulation</div>
<div className="sdesc">Configure a price change scenario and see volume, value and GC impacts across 4 competitive response cases.</div>
{/* Inputs */}
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginBottom: 20, background: '#fff', border: '1px solid var(--bd)', borderRadius: 12, padding: 16 }}>
{/* Grain filters */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', flex: 1 }}>
<div className="fg">
<div className="fl">Channel</div>
<select value={fch} onChange={e => setFch(e.target.value)}>
<option value="ALL">All</option><option value="MT">MT</option><option value="TT">TT</option>
</select>
</div>
<div className="fg">
<div className="fl">Region</div>
<select value={frg} onChange={e => setFrg(e.target.value)}>
<option value="ALL">All</option>
{['All India','East','North','South','West'].map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="fg">
<div className="fl">Pack</div>
<select value={fpack} onChange={e => setFpack(e.target.value)}>
<option value="ALL">All packs</option>
{['20-30','33-80','85-150','170-240','250-400','400+'].map(p => <option key={p} value={p}>{p}ml</option>)}
</select>
</div>
</div>
{/* Price inputs */}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div className="sim-f">
<div className="sim-lbl">{brandLabel} Price Change %</div>
<input type="number" value={dP} step={0.5}
onChange={e => setDP(parseFloat(e.target.value) || 0)}
style={{ width: 120, padding: '7px 10px', border: '1px solid var(--bd2)', borderRadius: 7, fontSize: 13, fontFamily: 'var(--mono)' }} />
</div>
{competitors.length > 0 && (
<div className="comp-section" style={{ minWidth: 260 }}>
<div className="comp-sec-ttl">Competitor Response %</div>
{competitors.map(brand => (
<div key={brand} className="comp-row">
<div className="comp-lbl">{toLabel(brand)}</div>
<input type="number" className="comp-inp" value={syncedCompResp[brand] ?? 0} step={0.5}
onChange={e => setCompResp(prev => ({ ...prev, [brand]: parseFloat(e.target.value) || 0 }))} />
</div>
))}
</div>
)}
</div>
</div>
<div style={{ fontSize: 10, color: 'var(--mt)', marginBottom: 12, fontFamily: 'var(--mono)' }}>
{grains.length} grains matched · GC = vol change × price/ml × 1000 × GC%
</div>
{/* Scenario cards */}
{grains.length === 0
? <div className="no-data">No grains match selected filters.</div>
: (
<div className="sc-grid">
{scenarios.map(sc => <ScenarioCard key={sc.num} sc={sc} />)}
</div>
)
}
</div>
)
}
function ScenarioCard({ sc }) {
return (
<div className="s-card">
<div className="s-hdr">
<div className={`s-num ${sc.cls}`}>{sc.num}</div>
<div>
<div className="s-name">{sc.name}</div>
<div style={{ fontSize: 9, color: 'var(--mt)' }}>{sc.desc}</div>
</div>
</div>
{/* Per-grain rows */}
<div style={{ marginBottom: 8 }}>
{sc.impacts.map((x, i) => (
<div key={i} className="ir">
<span style={{ fontSize: 9, fontFamily: 'var(--mono)', color: 'var(--mt)' }}>
{x.m.Pack}·{x.m.Channel}·{x.m.Region.slice(0, 4)}
</span>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--mono)', fontSize: '9.5px', color: x.totPct >= 0 ? 'var(--gn)' : 'var(--rd)' }}>
{x.totPct >= 0 ? '+' : ''}{(x.totPct * 100).toFixed(1)}%
</span>
<span style={{ fontFamily: 'var(--mono)', fontSize: '8.5px', color: 'var(--mt)' }}>
{fmtCr(x.valRs)}
</span>
<span style={{ fontFamily: 'var(--mono)', fontSize: '8.5px', color: x.gcRs >= 0 ? 'var(--tl)' : '#c07000' }}>
GC:{fmtCr(x.gcRs)}
</span>
</div>
</div>
))}
</div>
{/* Portfolio summary */}
<div style={{ borderTop: '2px solid var(--bd)', paddingTop: 9 }}>
<div style={{ fontSize: '9.5px', fontWeight: 700, color: 'var(--mt)', marginBottom: 6 }}>Portfolio Impact</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<KpiBox label="VOL CHANGE" value={(sc.wtdPct >= 0 ? '+' : '') + (sc.wtdPct * 100).toFixed(1) + '%'}
sub={fmtL(sc.totVolL)} color={sc.wtdPct >= 0 ? 'var(--gn)' : 'var(--rd)'} />
<KpiBox label="VALUE IMPACT" value={fmtCr(sc.totValRs)} sub="@ MRP"
color={sc.totValRs >= 0 ? 'var(--bl)' : 'var(--rd)'} />
<KpiBox label="GC IMPACT" value={fmtCr(sc.totGcRs)} sub="Gross Contribution"
color={sc.totGcRs >= 0 ? 'var(--tl)' : 'var(--rd)'} />
</div>
</div>
</div>
)
}
function KpiBox({ label, value, sub, color }) {
return (
<div style={{ background: 'var(--sf)', border: '1px solid var(--bd)', borderRadius: 6, padding: '7px 9px', textAlign: 'center' }}>
<div style={{ fontSize: 8, color: 'var(--mt)', textTransform: 'uppercase', letterSpacing: '.4px', fontFamily: 'var(--mono)' }}>{label}</div>
<div style={{ fontFamily: 'var(--mono)', fontSize: 15, fontWeight: 700, color }}>{value}</div>
<div style={{ fontSize: 8, color: 'var(--mt)', fontFamily: 'var(--mono)' }}>{sub}</div>
</div>
)
}