/* ============================================================
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 (
Unidades del día
{dayUnits}
Venta estimada
{store.money(dayRevenue)}
según precios actuales
store.saveSalesBatch(date, entries)}> Guardar consumos
{dirty &&
Hay cambios sin guardar
}
{data.cartas.map(c=>(
setCartaId(c.id)} style={cartaId===c.id?accentVars(c.id):null}>
{c.name}
))}
{rows.length===0 ?
Sin productos para mostrar.
:
rows.map(({p,cat})=>(
{p.featured&&}{p.name}
{cat.name} · {store.money(effectivePrice(p))}
setQty(p.id,(entries[p.id]||0)-1)}>
setQty(p.id,e.target.value)} inputMode="numeric"/>
setQty(p.id,(entries[p.id]||0)+1)}>
))}
);
}
/* ---- 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 (
{const [y,m]=month.split('-').map(Number);const d=new Date(y,m-2,1);setMonth(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`);}}>
{monthLabel(month)}
{const [y,m]=month.split('-').map(Number);const d=new Date(y,m,1);setMonth(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`);}}>
);
}
/* ---- 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)}
# Producto Unid. Mix Venta
{top.slice(0,12).map((x,i)=>(
{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 (
setMetric('revenue')}>Ventas $
setMetric('units')}>Unidades
{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)}
Producto Unid. Precio Costo Margen Clase
{withCost.sort((a,b)=>b.u-a.u).map(x=>{const k=classify(x);return(
{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=>(setTab(t.k)}>{t.l} ))}
{tab==='consumos'&&
}
{tab==='resumen'&&
}
{tab==='historico'&&
}
{tab==='ingenieria'&&
}
);
}
Object.assign(window, { Dashboard });