/* global React, window */ // Ruticomidas — screens // Each screen is a self-contained function exported on window.RC_SCREENS. const { useState, useEffect, useMemo, useCallback, useRef } = React; const D = window.RC_DATA; // -------- Small helpers -------- function Masthead({ kicker, title, deck, stats, actions }) { return (
{kicker &&
{kicker}
}

{title}

{deck &&
{deck}
} {actions &&
{actions}
}
{stats &&
{stats}
}
); } function Stat({ label, value, delta, down }) { return (
{label}
{value}
{delta &&
{delta}
}
); } function SectionHead({ title, sub, right }) { return (

{title}

{sub &&
{sub}
}
{right}
); } // -------- PLANNING -------- function PlanningScreen() { const store = window.RC_STORE.useStore(); const { planningWeek, weekOffset, changeWeek, recipes, recipeCosts, reload } = store; const [showCost, setShowCost] = useState(false); const [applyingTemplate, setApplyingTemplate] = useState(false); const MEAL_SLOTS = D.MEAL_SLOTS; const days = planningWeek?.days || []; const planning = planningWeek?.planning || []; const meals = planning.flat().filter(Boolean).reduce((s, m) => s + (m.recipes?.length || 0), 0); const loose = planning.flat().filter(Boolean).reduce((s, m) => s + (m.loose?.length || 0), 0); const weekLabel = planningWeek?.weekLabel || ''; const FALLBACK_TONES = ['mint', 'yellow', 'pink', 'orange', 'purple']; const toneByName = useMemo(() => { const m = {}; (recipes || []).forEach(r => { m[r.name] = r.tone; }); return m; }, [recipes]); const toneFor = (name) => { if (!name) return 'mint'; if (toneByName[name]) return toneByName[name]; let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return FALLBACK_TONES[h % FALLBACK_TONES.length]; }; const todayIdx = days.findIndex(d => d.today); const isPast = (dIdx) => todayIdx >= 0 && dIdx < todayIdx; const openAddRecipeModal = (dIdx, sIdx) => { window.dispatchEvent(new CustomEvent('rc:modal', { detail: { name: 'meal', props: { day: days[dIdx], slot: MEAL_SLOTS[sIdx], recipes, onSave: reload.planning }, }, })); }; const openLooseModal = (e, dIdx, sIdx) => { e.stopPropagation(); window.dispatchEvent(new CustomEvent('rc:modal', { detail: { name: 'loose', props: { day: days[dIdx], slot: MEAL_SLOTS[sIdx], onSave: reload.planning }, }, })); }; const openRecipeDetailModal = (e, r) => { e.stopPropagation(); window.dispatchEvent(new CustomEvent('rc:modal', { detail: { name: 'planning-recipe', props: { planRecipe: r, recipe: (recipes || []).find(x => x.id === r.recipeId) || null, onSave: reload.planning, }, }, })); }; const deleteLoose = async (e, lid) => { e.stopPropagation(); try { await window.api(`/api/planning/loose/${lid}`, 'DELETE'); await reload.planning(); } catch (err) { window.showToast(err.message, 'error'); } }; const applyTemplate = async () => { setApplyingTemplate(true); try { await window.api('/api/template/apply', 'POST', {}); await reload.planning(); window.showToast('Plantilla aplicada'); } catch (e) { window.showToast(e.message, 'error'); } finally { setApplyingTemplate(false); } }; const weekCostRows = useMemo(() => { if (!planningWeek || !recipeCosts) return []; const rows = []; (planningWeek.planning || []).forEach((daySlots, dIdx) => { const day = (planningWeek.days || [])[dIdx]; (daySlots || []).forEach((meal, sIdx) => { if (!meal) return; const slot = (D.MEAL_SLOTS || [])[sIdx]; (meal.recipes || []).forEach(r => { const cost = recipeCosts[r.recipeId]; rows.push({ day: day?.dow || '', date: day?.date || '', slot: slot?.label || '', name: r.title, raciones: r.raciones || 1, cost: cost ? cost.total * (r.raciones || 1) : null, partial: cost?.partial || false, }); }); }); }); return rows; }, [planningWeek, recipeCosts]); const weekTotalCost = weekCostRows.reduce((s, r) => s + (r.cost || 0), 0); return ( <> {weekTotalCost > 0 && } } />
{weekLabel}
● STOCK SE DESCUENTA AL FINALIZAR EL DÍA
{showCost && (
Coste estimado de la semana
* = datos parciales (sin precio de algún ingrediente)
{weekTotalCost.toFixed(2)}€
{weekCostRows.length === 0 &&
Sin comidas planificadas esta semana.
} {weekCostRows.length > 0 && ( {weekCostRows.map((r, i) => ( ))}
DíaComidaRecetaRac.Coste
{r.day} {r.slot} {r.name} {r.raciones} {r.cost !== null ? {r.cost.toFixed(2)}€{r.partial ? '*' : ''} : }
TOTAL SEMANA {weekTotalCost.toFixed(2)}€
)}
)}
{days.map((d, i) => (
{d.dow}{d.today && ' · Hoy'}
{d.dom}
))} {MEAL_SLOTS.map((slot, sIdx) => (
{slot.label}
{days.map((day, dIdx) => { const meal = planning[dIdx]?.[sIdx]; const recipeList = meal?.recipes || []; const firstTone = recipeList.length > 0 ? toneFor(recipeList[0].title) : null; const cellClasses = ['plan-cell']; if (day.today) cellClasses.push('today'); if (isPast(dIdx)) cellClasses.push('past'); if (!meal) { cellClasses.push('empty'); } else if (firstTone) { cellClasses.push('tone-' + firstTone); } return (
{recipeList.map(r => { const rc = recipeCosts[r.recipeId]; const costVal = rc ? rc.total * (r.raciones || 1) : null; return (
openRecipeDetailModal(e, r)}>
{r.title}
{r.raciones} RAC
{costVal !== null && (
{costVal.toFixed(2)}€{rc.partial ? '*' : ''}
)}
); })} {meal && (meal.loose || []).length > 0 && (
+ EXTRA
{meal.loose.map(l => (
{l.name} {l.qty} {l.unit}
))}
)}
); })}
))}
); } // -------- RECETAS -------- function RecetasScreen() { const store = window.RC_STORE.useStore(); const { recipes, recipeCosts, reload } = store; const [filter, setFilter] = useState('Todas'); // costDetails[recipeId] = array of {name, qty, unit, cost|null} (lazy fetched on hover) const [costDetails, setCostDetails] = useState({}); const filters = ['Todas', 'Desayuno', 'Comida', 'Merienda', 'Cena', 'Pública']; const list = useMemo(() => { if (filter === 'Todas') return recipes; if (filter === 'Pública') return recipes.filter(r => r.public); return recipes.filter(r => (r.kinds || []).some(k => k.toLowerCase().includes(filter.toLowerCase()))); }, [recipes, filter]); const openRecipeModal = (r) => { window.dispatchEvent(new CustomEvent('rc:modal', { detail: { name: 'recipe', props: { recipe: r, onSave: reload.recipes } } })); }; const fetchCostDetail = async (rid) => { if (costDetails[rid] !== undefined) return; // already fetched or fetching setCostDetails(prev => ({ ...prev, [rid]: null })); // mark as loading try { const data = await window.api(`/api/recipes/${rid}/cost`); setCostDetails(prev => ({ ...prev, [rid]: data?.items || [] })); } catch (_) { setCostDetails(prev => ({ ...prev, [rid]: [] })); } }; return ( <> } />
{filters.map(f => ( ))}
{list.length} recetas
{list.map((r) => { const rc = recipeCosts[r.id]; const ingItems = costDetails[r.id]; // null=loading, undefined=not fetched, array=ready const ings = r.ingredients || []; return (
openRecipeModal(r)} onMouseEnter={() => fetchCostDetail(r.id)} >
{(r.kinds[0] || 'receta').toUpperCase()} {r.public && ( PÚBLICA )}

{r.name}

{ings.length > 0 && (
{ings.slice(0, 6).map((ing, ii) => { const ingName = ing.name || ing.ingredient_name; const priceItem = Array.isArray(ingItems) && ingItems.find(it => it.name === ingName); return (
{ingName} {ing.qty} {ing.unit} {priceItem && priceItem.cost != null ? priceItem.cost.toFixed(2) + '€' : ''}
); })} {ings.length > 6 && (
+{ings.length - 6} MÁS
)}
)}
{ings.length} ingredientes{rc?.partial ? '*' : ''}
{rc && rc.total != null && ( {rc.total.toFixed(2)}€ )}
); })} {list.length === 0 && (
No hay recetas. Crea la primera.
)}
); } // -------- COMPRA -------- const MKT_COLORS = { Mercadona: 'mint', Lidl: 'yellow', Carrefour: 'orange' }; function CompraScreen() { const [shoppingList, setShoppingList] = useState(null); const [checks, setChecks] = useState({}); const pendingRemoval = useRef({}); useEffect(() => { return () => { Object.values(pendingRemoval.current).forEach(clearTimeout); }; }, []); const weekStart = (() => { const today = new Date(); const dow = (today.getDay() + 6) % 7; const m = new Date(today); m.setDate(today.getDate() - dow); return m.toISOString().slice(0, 10); })(); useEffect(() => { window.api('/api/shopping') .then(r => setShoppingList(r || [])) .catch(e => window.showToast(e.message, 'error')); }, []); const toggle = async (k, item) => { const nowOn = !checks[k]; setChecks(c => ({ ...c, [k]: nowOn })); if (nowOn && item.id) { try { await window.api('/api/shopping/buy', 'POST', { ingredient_id: item.id, qty: (item.to_buy || 0) * (item.packs || 1), ingredient_name: item.name, week_start: weekStart, unset_manual: false, reset_extra_packs: false, }); clearTimeout(pendingRemoval.current[k]); pendingRemoval.current[k] = setTimeout(() => { setShoppingList(l => l ? l.filter(x => String(x.id || x.name) !== k) : l); }, 2500); } catch (e) { window.showToast(e.message, 'error'); setChecks(c => ({ ...c, [k]: false })); } } }; const byMarket = (shoppingList || []).reduce((acc, it) => { const mkt = it.supermarket || 'Otros'; if (!acc[mkt]) acc[mkt] = []; acc[mkt].push(it); return acc; }, {}); const markets = Object.entries(byMarket); const totalItems = (shoppingList || []).length; const totalChecked = Object.values(checks).filter(Boolean).length; return ( <> } actions={ <> } />
AL MARCAR UN ÍTEM SE SUMA AUTOMÁTICAMENTE AL STOCK DEL ARTÍCULO
{shoppingList === null && (
Cargando lista…
)} {shoppingList !== null && markets.length === 0 && (
Lista vacía. Asigna recetas al planning para generar la compra.
)} {markets.map(([market, items]) => (
{market} {items.length} items
    {items.map((it) => { const k = String(it.id || it.name); const on = !!checks[k]; const qtyLabel = it.packs > 1 ? `× ${it.packs} envases` : `${(it.to_buy || 0).toFixed(1)} ${it.unit}`; return (
  • {it.name}
    {qtyLabel}
    {it.manual &&
    manual
    }
  • ); })}
))}
); } // -------- SUGERENCIAS -------- const MEAL_OPTS_SUG = [ { v: '', l: 'Todas' }, { v: 'desayuno', l: 'Desayuno' }, { v: 'comida', l: 'Comida' }, { v: 'merienda', l: 'Merienda' }, { v: 'cena', l: 'Cena' }, ]; function SugerenciasScreen() { const { recipes, ingredients } = window.RC_STORE.useStore(); const [mealFilter, setMealFilter] = useState(''); const stock = useMemo(() => { const s = {}; (ingredients || []).forEach(i => { s[i.name] = i.stock ?? 0; }); return s; }, [ingredients]); const { puedes, falta } = useMemo(() => { const puedes = [], falta = []; (recipes || []).forEach(r => { if (mealFilter && r.kinds && !r.kinds.includes(mealFilter)) return; const ings = r.ingredients || []; if (!ings.length) return; const missing = ings.filter(ing => (stock[ing.name] ?? 0) < (ing.qty ?? 0)); if (missing.length === 0) puedes.push({ r, missing: [] }); else if (missing.length <= 2) falta.push({ r, missing: missing.map(i => i.name) }); }); return { puedes, falta }; }, [recipes, stock, mealFilter]); return ( <> } />
{MEAL_OPTS_SUG.map(o => ( ))}
{puedes.length === 0 &&
Ninguna receta con todo el stock.
}
{puedes.map(({ r }) => (
{(r.kinds[0] || 'receta').toUpperCase()}

{r.name}

{(r.ingredients || []).length} ingredientes
))}
{falta.length === 0 &&
Ninguna receta aquí.
}
{falta.map(({ r, missing }) => (
{(r.kinds[0] || 'receta').toUpperCase()}

{r.name}

Falta: {missing.join(', ')}
))}
); } // -------- GASTOS / RESUMEN -------- const GASTOS_CAT_COLORS = ['#3cffd0','#5200ff','#ffd166','#ef476f','#06d6a0','#118ab2','#ff9f1c','#e63946']; function GastosScreen() { const store = window.RC_STORE.useStore(); const { budgets: rawBudgets, reload: storeReload } = store; const [monthOffset, setMonthOffset] = useState(0); const [rawSummary, setRawSummary] = useState(null); const [prevTotal, setPrevTotal] = useState(null); const getMonthDate = useCallback((offset) => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() + offset); return d.toISOString().slice(0, 10); }, []); const monthLabel = useMemo(() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() + monthOffset); const lbl = d.toLocaleString('es-ES', { month: 'long', year: 'numeric' }); return lbl.charAt(0).toUpperCase() + lbl.slice(1); }, [monthOffset]); useEffect(() => { const date = getMonthDate(monthOffset); const datePrev = getMonthDate(monthOffset - 1); Promise.all([ window.api(`/api/expenses/summary?period=month&date=${date}`), window.api(`/api/expenses/summary?period=month&date=${datePrev}`), ]) .then(([cur, prev]) => { setRawSummary(cur || null); setPrevTotal(prev?.total ?? null); }) .catch(e => window.showToast(e.message, 'error')); }, [monthOffset, getMonthDate]); const byCat = useMemo(() => { const m = {}; ((rawSummary || {}).by_category || []).forEach(c => { m[c.name] = c; }); return m; }, [rawSummary]); const budgets = useMemo(() => (rawBudgets || []).map((b, i) => { const spent = (byCat[b.name]?.total) || 0; const limit = b.monthly_limit || 0; const pct = limit > 0 ? Math.round((spent / limit) * 100) : 0; return { id: b.id, cat: b.name, icon: b.icon || '📦', spent, limit, pct, over: limit > 0 && spent > limit, color: GASTOS_CAT_COLORS[i % GASTOS_CAT_COLORS.length], }; }), [rawBudgets, byCat]); const totalMonth = rawSummary?.total || 0; const topCat = ((rawSummary || {}).by_category || [])[0] || { name: '—', total: 0 }; const budget = budgets.reduce((s, b) => s + b.limit, 0); const vsLast = (prevTotal != null && prevTotal > 0) ? Math.round(((totalMonth - prevTotal) / prevTotal) * 100) : null; const k = { totalMonth, budget, topCategory: topCat.name, topCategoryAmount: topCat.total }; const weekBars = ((rawSummary || {}).evolution || []).map((e, i) => ({ lbl: `S${i + 1}`, v: e.total || 0, c: i % 2 === 0 ? 'mint' : 'ultra', })); const topPlaces = ((rawSummary || {}).top_places || []).map(p => ({ name: p.name, v: p.total })); const byPayment = (rawSummary || {}).by_payment || {}; const paymentEntries = Object.entries(byPayment).sort((a, b) => b[1] - a[1]); const total = budgets.reduce((sum, b) => sum + b.spent, 0); let acc = 0; const stops = budgets.length > 0 ? budgets.map(b => { const start = total > 0 ? (acc / total) * 360 : 0; acc += b.spent; const end = total > 0 ? (acc / total) * 360 : 0; return `${b.color} ${start}deg ${end}deg`; }).join(', ') : 'var(--text-meta) 0deg 360deg'; const barMax = weekBars.length > 0 ? Math.max(...weekBars.map(b => b.v), 1) : 1; const plMax = topPlaces.length > 0 ? topPlaces[0].v : 1; return ( <> } />
{monthLabel}
GASTO TOTAL
{k.totalMonth.toFixed(2)}€
DE {k.budget.toFixed(0)}€{k.budget > 0 ? ` · ${Math.round((k.totalMonth/k.budget)*100)}%` : ''}
VS. MES ANTERIOR
{vsLast == null ? '—' : (vsLast >= 0 ? '+' : '') + vsLast + '%'}
TOP CATEGORÍA
{k.topCategoryAmount.toFixed(2)}€
{k.topCategory.toUpperCase()}
MÉTODO DE PAGO
{paymentEntries.length > 0 ? paymentEntries[0][1].toFixed(2) + '€' : '—'}
{paymentEntries.map(([k, v]) => `${k.toUpperCase()} ${v.toFixed(2)}€`).join(' · ') || '—'}
{budgets.map(b => (
{b.icon}
{b.cat}
{b.pct}%
{b.spent.toFixed(2)}€ {b.limit > 0 && DE {b.limit.toFixed(0)}€}
))} {budgets.length === 0 &&
Sin categorías aún.
}
{k.totalMonth.toFixed(0)}€
TOTAL MES
{budgets.filter(b => b.spent > 0).map(b => (
{b.cat} {total > 0 ? Math.round((b.spent / total) * 100) : 0}% {b.spent.toFixed(0)}€
))}
{weekBars.map(b => (
{b.v.toFixed(0)}€
{b.lbl}
))} {weekBars.length === 0 &&
Sin datos.
}
{topPlaces.map((p, i) => (
0{i + 1} {p.name}
{p.v.toFixed(2)}€
))} {topPlaces.length === 0 &&
Sin datos.
}
); } // -------- TICKETS / OCR -------- const TICKET_PERIODS = [ { id: 'month', label: 'Este mes' }, { id: 'lastmonth', label: 'Mes anterior' }, { id: 'year', label: 'Este año' }, { id: 'all', label: 'Todo' }, ]; function TicketsScreen() { const [queuePending, setQueuePending] = useState([]); const [ticketStats, setTicketStats] = useState(null); const [topProducts, setTopProducts] = useState([]); const [recentTickets, setRecentTickets] = useState([]); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState('month'); const [priceQuery, setPriceQuery] = useState(''); const [priceResults, setPriceResults] = useState(null); const [priceLoading, setPriceLoading] = useState(false); const buildRecentUrl = (p) => { const now = new Date(); if (p === 'month') { return `/api/tickets/recent?limit=50&year=${now.getFullYear()}&month=${now.getMonth() + 1}`; } if (p === 'lastmonth') { const d = new Date(now.getFullYear(), now.getMonth() - 1, 1); return `/api/tickets/recent?limit=50&year=${d.getFullYear()}&month=${d.getMonth() + 1}`; } if (p === 'year') return `/api/tickets/recent?limit=200&year=${now.getFullYear()}`; return '/api/tickets/recent?limit=200'; }; const loadData = useCallback(async (p) => { setLoading(true); try { const [stats, queue, tops, recent] = await Promise.all([ window.api('/api/tickets/stats'), window.api('/api/tickets/queue/pending'), window.api('/api/tickets/top-products?limit=5'), window.api(buildRecentUrl(p)), ]); setTicketStats(stats || {}); setQueuePending(queue || []); setTopProducts(tops || []); setRecentTickets(recent || []); } catch (e) { window.showToast(e.message, 'error'); } finally { setLoading(false); } }, []); useEffect(() => { loadData(period); }, [period]); const doPriceSearch = useCallback(async () => { const q = priceQuery.trim(); if (q.length < 2) { window.showToast('Escribe al menos 2 caracteres', 'error'); return; } setPriceLoading(true); try { const r = await window.api(`/api/tickets/price-search?q=${encodeURIComponent(q)}`); setPriceResults(r || []); } catch (e) { window.showToast(e.message, 'error'); } finally { setPriceLoading(false); } }, [priceQuery]); const deleteTicket = async (id) => { try { await window.api(`/api/tickets/${id}`, 'DELETE'); setRecentTickets(prev => prev.filter(t => t.id !== id)); window.showToast('Ticket eliminado'); } catch (e) { window.showToast(e.message, 'error'); } }; const reparseTicket = async (id) => { try { await window.api(`/api/tickets/${id}/reparse`, 'POST', {}); window.showToast('Reprocesando con Gemini… puede tardar unos segundos'); setTimeout(() => loadData(period), 3000); } catch (e) { window.showToast(e.message, 'error'); } }; const s = ticketStats || { ticket_count: 0, total_spent: 0, avg_ticket: 0 }; const maxFreq = topProducts.length > 0 ? topProducts[0].times_bought || 1 : 1; return ( <> } />
{queuePending.length > 0 && (
● {queuePending.length} TICKET{queuePending.length > 1 ? 'S' : ''} LISTO{queuePending.length > 1 ? 'S' : ''} PARA VERIFICAR
)}
TICKETS DEL MES
{s.ticket_count}
GASTO TOTAL
{(s.total_spent || 0).toFixed(0)}€
TICKET MEDIO
{(s.avg_ticket || 0).toFixed(2)}€
COLA PENDIENTE
0 ? 'var(--tile-orange)' : undefined }}>{queuePending.length}
LISTOS PARA VERIFICAR
loadData(period)}>↻} /> {loading &&
Cargando…
} {!loading && queuePending.length === 0 && (
Cola vacía. Sube una foto para procesar un ticket.
)}
{queuePending.map(item => { const f = window.RC_ticketFlat(item.ticket_data); const when = item.created_at ? new Date(item.created_at).toLocaleString('es-ES', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; return (
{when}
window.rcOpen('ticket', { queueItem: item, onSave: () => loadData(period) })}>
LISTO #{item.id}
{f.supermarket || 'Supermercado'}
{f.items.length} ítems {f.date || ''}
ev.stopPropagation()}>
{(f.total ?? 0).toFixed(2)}€
); })}
{topProducts.length === 0 &&
Sin datos aún.
} {topProducts.map((p, i) => (
{String(i + 1).padStart(2, '0')} {p.name}
{(p.avg_price || 0).toFixed(2)}€
×{p.times_bought} TICKETS
))}
BÚSQUEDA DE PRECIO
Histórico
Encuentra el mejor supermercado para un producto.
{ setPriceQuery(e.target.value); setPriceResults(null); }} onKeyDown={e => e.key === 'Enter' && doPriceSearch()} style={{ flex: 1, background: 'rgba(0,0,0,0.06)', borderColor: 'rgba(0,0,0,0.2)', color: '#000' }} />
{priceResults !== null && (
{priceResults.length === 0 && (
Sin resultados.
)} {priceResults.map((r, i) => (
window.rcOpen('ticket', { ticketId: r.ticket_id, onSave: () => loadData(period) })} >
{r.product_name || r.name}
{r.supermarket} · {r.date}
{(r.unit_price || 0).toFixed(2)}€/{r.unit || 'ud'}
))}
)}
{TICKET_PERIODS.map(p => ( ))}
{loading &&
Cargando…
} {!loading && recentTickets.length === 0 && (
Sin tickets confirmados en este periodo.
)} {!loading && recentTickets.length > 0 && (
{recentTickets.map(t => ( ))}
# Supermercado Fecha Ítems Total
#{t.id}
window.rcOpen('ticket', { ticketId: t.id, onSave: () => loadData(period) })}>{t.supermarket || '—'}
{(t.date || '').toUpperCase()} {(t.items || []).length} {(t.total || 0).toFixed(2)}€
)}
); } // -------- Placeholder for less-developed screens -------- function PlaceholderScreen({ kicker, title, deck }) { return ( <>
WIP · PRÓXIMAMENTE
Pantalla en construcción
Esta vista se desarrollará en la siguiente iteración del prototipo. La estructura del backend ya está cubierta por los endpoints correspondientes.
); } window.RC_SCREENS = { planning: () => , recetas: () => , compra: () => , sugerencias: () => , gastos: () => , tickets: () => , // remaining screens are attached by screens-extra.jsx };