/* ============================================================ DASHBOARD · consumos, resumen, histórico, ingeniería de menú ============================================================ */ const MONTHS_ES = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; const effectivePrice = (p) => (p && p.price != null) ? p.price : ((p && p.variants && p.variants[0] && p.variants[0].price) || 0); const curMonth = () => { const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }; const monthLabel = (m) => { const [y,mo]=m.split('-'); return `${MONTHS_ES[+mo-1]} ${y}`; }; /* ---- daily consumption entry ---- */ function ConsumosDia() { const store = useStore(); const { data, productIndex } = store; const [date, setDate] = useState(store.todayISO()); const [cartaId, setCartaId] = useState(data.cartas[0].id); const [query, setQuery] = useState(''); const [entries, setEntries] = useState({}); const [onlyWith, setOnlyWith] = useState(false); useEffect(() => { setEntries({ ...(data.sales[date] || {}) }); }, [date, data.sales]); const carta = data.cartas.find(c => c.id === cartaId); const q = query.trim().toLowerCase(); const rows = useMemo(() => { const out = []; carta.categories.forEach(cat => cat.subcats.forEach(s => s.products.forEach(p => { if (q && !p.name.toLowerCase().includes(q)) return; if (onlyWith && !(entries[p.id] > 0)) return; out.push({ p, cat }); }))); return out; }, [carta, q, onlyWith, entries]); const setQty = (pid, v) => { const n = Math.max(0, parseInt(v||0,10)||0); setEntries(e => ({ ...e, [pid]: n })); }; const dirty = JSON.stringify(entries) !== JSON.stringify(data.sales[date] || {}); const dayUnits = Object.values(entries).reduce((a,b)=>a+b,0); const dayRevenue = Object.entries(entries).reduce((a,[pid,q])=>a + q*effectivePrice(productIndex[pid]),0); return (
Fecha seleccionada
setDate(e.target.value)} style={{marginTop:8,fontFamily:'var(--font-display)',fontSize:18}}/>
Unidades del día
{dayUnits}
Venta estimada
{store.money(dayRevenue)}
según precios actuales
{dirty &&
Hay cambios sin guardar
}

Registro de consumos

setQuery(e.target.value)} placeholder="Buscar producto…" style={{width:140}}/>
{data.cartas.map(c=>( ))}
{rows.length===0 ?
Sin productos para mostrar.
: rows.map(({p,cat})=>(
{p.featured&&}{p.name}
{cat.name} · {store.money(effectivePrice(p))}
setQty(p.id,e.target.value)} inputMode="numeric"/>
))}
); } /* ---- monthly aggregation ---- */ function useMonthAgg(month) { const { data, productIndex } = useStore(); return useMemo(() => { const perProduct = {}; let totalUnits=0, totalRevenue=0; const perCategory={}; const perCarta={}; const daily = {}; Object.entries(data.sales).forEach(([date, items]) => { if (!date.startsWith(month)) return; let dRev=0, dUnits=0; Object.entries(items).forEach(([pid, qty]) => { const p = productIndex[pid]; if (!p) return; const rev = qty * effectivePrice(p); perProduct[pid] = (perProduct[pid]||0) + qty; totalUnits += qty; totalRevenue += rev; perCategory[p.catName] = (perCategory[p.catName]||0) + rev; perCarta[p.cartaName] = (perCarta[p.cartaName]||0) + rev; dRev += rev; dUnits += qty; }); daily[date] = { revenue:dRev, units:dUnits }; }); return { perProduct, totalUnits, totalRevenue, perCategory, perCarta, daily }; }, [data.sales, productIndex, month]); } function MonthPicker({ month, setMonth }) { return (
{monthLabel(month)}
); } /* ---- resumen mensual ---- */ function ResumenMes() { const store = useStore(); const { productIndex } = store; const [month, setMonth] = useState(curMonth()); const agg = useMonthAgg(month); const top = Object.entries(agg.perProduct).map(([pid,u])=>({p:productIndex[pid],u,rev:u*effectivePrice(productIndex[pid])})).filter(x=>x.p).sort((a,b)=>b.u-a.u); const maxU = top[0]?.u || 1; const cats = Object.entries(agg.perCategory).sort((a,b)=>b[1]-a[1]); const maxCat = cats[0]?.[1] || 1; const daysWithData = Object.keys(agg.daily).length; return (
Venta del mes
{store.money(agg.totalRevenue)}
Unidades vendidas
{agg.totalUnits}
Ticket prom. / día
{store.money(daysWithData?agg.totalRevenue/daysWithData:0)}
{daysWithData} días con registro
Productos distintos
{top.length}
{top.length===0 ?
No hay consumos registrados en {monthLabel(month)}. Regístralos en la pestaña “Consumos”.
: (

Más vendidos

top {Math.min(12,top.length)}
{top.slice(0,12).map((x,i)=>( ))}
#ProductoUnid.MixVenta
{i+1} {x.p.name}
{x.p.cartaName}
{x.u}
{store.money(x.rev)}

Venta por categoría

{cats.map(([name,rev])=>( ))}
{name}
{store.money(rev)}
)}
); } /* ---- histórico (gráfico por fechas) ---- */ function Historico() { const store = useStore(); const [month, setMonth] = useState(curMonth()); const [metric, setMetric] = useState('revenue'); const agg = useMonthAgg(month); const [y,m] = month.split('-').map(Number); const days = new Date(y, m, 0).getDate(); const series = Array.from({length:days}, (_,i)=>{ const key=`${month}-${String(i+1).padStart(2,'0')}`; const d=agg.daily[key]||{revenue:0,units:0}; return { day:i+1, ...d }; }); const maxV = Math.max(1, ...series.map(s=>s[metric])); const W=720, H=240, pad=34, bw=(W-pad*2)/days; return (

{monthLabel(month)}

{metric==='revenue'?'Venta total: '+store.money(agg.totalRevenue):'Total: '+agg.totalUnits+' unid.'}
{[0,.25,.5,.75,1].map(t=>{const yy=pad+(H-pad*2)*(1-t);return {metric==='revenue'?'$'+Math.round(maxV*t/1000)+'k':Math.round(maxV*t)};})} {series.map(s=>{const h=(H-pad*2)*(s[metric]/maxV);const x=pad+s.day*bw-bw+bw*0.15;return ( {`${s.day} ${MONTHS_ES[m-1]}: ${metric==='revenue'?store.money(s.revenue):s.units+' u'}`} {(s.day===1||s.day%5===0||s.day===days)&&{s.day}} );})}
{agg.totalUnits===0 &&
Sin datos en este mes. Registra consumos para ver la tendencia.
}
); } /* ---- ingeniería de menú ---- */ function IngenieriaMenu() { const store = useStore(); const { productIndex } = store; const [month, setMonth] = useState(curMonth()); const agg = useMonthAgg(month); const sold = Object.entries(agg.perProduct).map(([pid,u])=>{ const p = productIndex[pid]; if(!p) return null; const price = effectivePrice(p); const cm = (p.cost!=null) ? price - p.cost : null; return { p, u, price, cm }; }).filter(Boolean); const withCost = sold.filter(x=>x.cm!=null); const noCost = sold.filter(x=>x.cm==null); const n = withCost.length; const totalU = withCost.reduce((a,b)=>a+b.u,0); const popThresh = n? (0.7*(totalU/n)) : 0; // 70% of avg units const avgCM = n? withCost.reduce((a,b)=>a+b.cm,0)/n : 0; const classify = (x)=>{ const hp=x.u>=popThresh, hm=x.cm>=avgCM; return hp&&hm?'star':hp&&!hm?'horse':!hp&&hm?'puzzle':'dog'; }; const groups = {star:[],horse:[],puzzle:[],dog:[]}; withCost.forEach(x=>groups[classify(x)].push(x)); const META = { star:{l:'Estrellas',d:'Alta popularidad + alto margen. Destácalas.'}, horse:{l:'Caballos de batalla',d:'Muy pedidas, margen bajo. Optimiza costos.'}, puzzle:{l:'Enigmas',d:'Buen margen, poca venta. Promociónalas.'}, dog:{l:'Perros',d:'Baja venta y margen. Revisar o retirar.'}, }; return (

Clasificación Kasavana-Smith según consumos y margen (precio − costo).

{noCost.length>0 && (
{noCost.length} producto(s) con ventas aún no tienen costo asignado y no se pueden clasificar. Agrega el costo en Contenido → editar producto → Costo para completar el análisis.
)} {withCost.length===0 ? (
Aún no hay productos con costo y consumos en {monthLabel(month)}.
Registra costos y ventas para activar la ingeniería de menú.
) : ( <>
{['star','horse','puzzle','dog'].map(k=>(
{META[k].l} · {groups[k].length}
{META[k].d}
{groups[k].sort((a,b)=>b.u-a.u).slice(0,6).map(x=>(
{x.p.name}{x.u}u · {store.money(x.cm)}
))} {groups[k].length===0&&}
))}

Detalle

umbral pop.: {popThresh.toFixed(1)}u · margen prom.: {store.money(avgCM)}
{withCost.sort((a,b)=>b.u-a.u).map(x=>{const k=classify(x);return( );})}
ProductoUnid.PrecioCostoMargenClase
{x.p.name}{x.u}{store.money(x.price)}{store.money(x.p.cost)}{store.money(x.cm)}{META[k].l.replace(/s$/,'')}
)}
); } /* ---- dashboard shell ---- */ function Dashboard() { const [tab, setTab] = useState('consumos'); const TABS = [ {k:'consumos', l:'Consumos del día', icon:'calendar'}, {k:'resumen', l:'Resumen mensual', icon:'chart'}, {k:'historico', l:'Histórico', icon:'trend'}, {k:'ingenieria', l:'Ingeniería de menú', icon:'grid'}, ]; return (

Dashboard

Registra consumos diarios y analiza el rendimiento de la carta.

{TABS.map(t=>())}
{tab==='consumos'&&} {tab==='resumen'&&} {tab==='historico'&&} {tab==='ingenieria'&&}
); } Object.assign(window, { Dashboard });