Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react' | |
| import { useData } from '../DataContext.jsx' | |
| export default function PriceGradient() { | |
| const { pgi } = useData() | |
| const [ch, setCh] = useState('TT') | |
| if (!pgi) return <div className="panel"><div className="no-data">Loadingβ¦</div></div> | |
| const d = pgi[ch] | |
| if (!d) return <div className="panel"><div className="no-data">No data for {ch}</div></div> | |
| const { rows, gi_c, gi_n, gi_chg } = d | |
| const giColor = gi_chg < 0 ? 'var(--gn)' : gi_chg > 0 ? 'var(--rd)' : 'var(--mt)' | |
| const dirColor = gi_n < 1 ? 'var(--gn)' : gi_n > 1 ? 'var(--rd)' : 'var(--mt)' | |
| const giSubLabel = gi_chg === 0 ? 'Unchanged' | |
| : (gi_chg < 0 ? 'Compressed ' : 'Stretched ') + (gi_chg < 0 ? '' : '+') + gi_chg.toFixed(2) + '%' | |
| return ( | |
| <div className="panel"> | |
| <div className="stitle">Price Gradient Index β Current vs Recommended</div> | |
| <div className="sdesc"> | |
| Reward gradient = % change in price/ml (PML) moving to the next pack size. | |
| Negative = discount for buying bigger (good). Positive premium = consumer penalised for upsizing (bad). | |
| GI = 400+ml PML Γ· 20-30ml PML. | |
| </div> | |
| {/* Channel filter */} | |
| <div className="fb" style={{ marginBottom: 20 }}> | |
| <div className="fg"> | |
| <div className="fl">Channel</div> | |
| <select value={ch} onChange={e => setCh(e.target.value)} style={{ fontSize: 12, padding: '6px 10px', border: '1px solid var(--bd2)', borderRadius: 7, background: '#fff', color: 'var(--tx)' }}> | |
| <option value="TT">TT β Traditional Trade</option> | |
| <option value="MT">MT β Modern Trade</option> | |
| </select> | |
| </div> | |
| </div> | |
| {/* KPI cards */} | |
| <div className="gi-kpi-row"> | |
| <div className="gi-kpi"> | |
| <div className="gi-kpi-lbl">GI Current</div> | |
| <div className="gi-kpi-val" style={{ color: 'var(--mt)' }}>{gi_c.toFixed(4)}</div> | |
| <div className="gi-kpi-sub">400+ml PML Γ· 20-30ml PML</div> | |
| </div> | |
| <div className="gi-kpi"> | |
| <div className="gi-kpi-lbl">GI Recommended</div> | |
| <div className="gi-kpi-val" style={{ color: giColor }}>{gi_n.toFixed(4)}</div> | |
| <div className="gi-kpi-sub">{giSubLabel}</div> | |
| </div> | |
| <div className="gi-kpi"> | |
| <div className="gi-kpi-lbl">Gradient Direction</div> | |
| <div className="gi-kpi-val" style={{ color: dirColor }}>{gi_n < 1 ? 'Inverted β' : 'Positive'}</div> | |
| <div className="gi-kpi-sub">{gi_n < 1 ? 'Large packs cheaper/ml' : 'Large packs costlier/ml'}</div> | |
| </div> | |
| </div> | |
| {/* Pack table */} | |
| <div style={{ background: '#fff', border: '1px solid var(--bd)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 6px rgba(0,0,0,.06)' }}> | |
| <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--bd)', background: '#f5f0e8' }}> | |
| <span style={{ fontSize: 13, fontWeight: 700 }}>Pack-level Detail</span> | |
| <span style={{ fontSize: 10, color: 'var(--mt)', marginLeft: 8 }}>Shaded rows = packs with recommended price change</span> | |
| </div> | |
| <div style={{ overflowX: 'auto', padding: 16 }}> | |
| <table className="gi-tbl"> | |
| <thead> | |
| <tr> | |
| <th>Pack</th><th>SKU (ml)</th><th>MRP current</th><th>MRP recommended</th> | |
| <th style={{ textAlign: 'center' }}>Change</th> | |
| <th>PML current</th><th>PML rec</th><th>Reward gradient (current β rec)</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rows.map((r, i) => { | |
| const changed = r.delta !== 0 | |
| const flipWarn = r.rg_c !== null && r.rg_n !== null && | |
| ((r.rg_c <= 0 && r.rg_n > 0) || (r.rg_c > 0 && r.rg_n < 0)) | |
| const rgStr = r.rg_c !== null && r.rg_n !== null | |
| ? `${r.rg_c >= 0 ? '+' : ''}${r.rg_c.toFixed(1)}% β ${r.rg_n >= 0 ? '+' : ''}${r.rg_n.toFixed(1)}%${flipWarn ? ' β ' : ''}` | |
| : 'β' | |
| const rgClass = r.rg_n === null ? 'gi-rg-neu' : r.rg_n > 5 ? 'gi-rg-bad' : r.rg_n <= 0 ? 'gi-rg-good' : 'gi-rg-neu' | |
| return ( | |
| <tr key={i} className={changed ? 'gi-row-changed' : ''}> | |
| <td style={{ fontWeight: 700 }}>{r.pack}ml</td> | |
| <td style={{ fontFamily: 'var(--mono)' }}>{r.sku}</td> | |
| <td style={{ fontFamily: 'var(--mono)' }}>βΉ{r.mrp_c.toFixed(2)}</td> | |
| <td style={{ fontFamily: 'var(--mono)' }}> | |
| <span className={r.delta > 0 ? 'gi-mrp-up' : r.delta < 0 ? 'gi-mrp-dn' : ''}> | |
| βΉ{r.mrp_n.toFixed(2)} | |
| </span> | |
| </td> | |
| <td style={{ textAlign: 'center' }}> | |
| {r.delta === 0 | |
| ? <span style={{ color: 'var(--mt)' }}>β</span> | |
| : <span className={`gi-delta ${r.delta > 0 ? 'gi-delta-up' : 'gi-delta-dn'}`}> | |
| {r.delta > 0 ? '+' : ''}{r.delta.toFixed(0)}% | |
| </span> | |
| } | |
| </td> | |
| <td style={{ fontFamily: 'var(--mono)' }}>βΉ{r.pml_c.toFixed(4)}</td> | |
| <td style={{ fontFamily: 'var(--mono)' }}> | |
| <span className={r.delta !== 0 ? (r.pml_n > r.pml_c ? 'gi-mrp-up' : 'gi-mrp-dn') : ''}> | |
| βΉ{r.pml_n.toFixed(4)} | |
| </span> | |
| </td> | |
| <td><span className={`gi-rg ${rgClass}`}>{rgStr}</span></td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Flags */} | |
| <GIFlags rows={rows} gi_n={gi_n} gi_chg={gi_chg} /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function GIFlags({ rows, gi_n, gi_chg }) { | |
| const flags = [] | |
| rows.forEach((r, i) => { | |
| if (r.rg_c !== null && r.rg_n !== null) { | |
| const flip = (r.rg_c <= 0 && r.rg_n > 0) || (r.rg_c > 0 && r.rg_n < 0) | |
| const fix = r.rg_c > 5 && r.rg_n <= 0 | |
| const prevPack = rows[i - 1] | |
| if (flip && r.rg_n > 0) { | |
| flags.push( | |
| <div key={`flip-${i}`} className="gi-flag gi-flag-warn"> | |
| <span className="gi-flag-icon">!</span> | |
| <div> | |
| <b>{prevPack ? prevPack.pack + 'ml' : 'β'}β{r.pack}ml step flips to premium (+{r.rg_n.toFixed(1)}%)</b> | |
| {' '}β after the {r.delta > 0 ? '+' : 'β'}{Math.abs(r.delta)}% change on {r.pack}ml, it becomes MORE expensive per ml than the previous pack. | |
| </div> | |
| </div> | |
| ) | |
| } | |
| if (fix) { | |
| flags.push( | |
| <div key={`fix-${i}`} className="gi-flag gi-flag-ok"> | |
| <span className="gi-flag-icon">β</span> | |
| <div> | |
| <b>{prevPack ? prevPack.pack + 'ml' : 'β'}β{r.pack}ml penalty removed</b> | |
| {' '}β current premium of +{r.rg_c.toFixed(1)}% becomes a reward of {r.rg_n.toFixed(1)}% after the price cut. | |
| </div> | |
| </div> | |
| ) | |
| } | |
| } | |
| }) | |
| if (gi_chg <= -10) { | |
| flags.push( | |
| <div key="gi-compress" className="gi-flag gi-flag-warn"> | |
| <span className="gi-flag-icon">!</span> | |
| <div> | |
| <b>GI compressed by {Math.abs(gi_chg).toFixed(1)}%</b> | |
| {' '}β large packs are now significantly cheaper per ml than small packs (GI={gi_n.toFixed(4)}). | |
| </div> | |
| </div> | |
| ) | |
| } | |
| if (!flags.length) return null | |
| return ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 8, margin: '0 16px 16px' }}> | |
| {flags} | |
| </div> | |
| ) | |
| } | |