Spaces:
Sleeping
Sleeping
| 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> | |
| ) | |
| } | |