/* global React, window */ // Ruticomidas — additional screens (config + remaining product screens). const { useState, useEffect, useMemo, useCallback } = React; const D = window.RC_DATA; // ---- Reusable masthead (duplicated to keep file independent) ---- function MH({ 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 SH({ title, sub, right }) { return (

{title}

{sub &&
{sub}
}
{right}
); } // ========================================================= // PLANTILLA — default weekly template // ========================================================= function PlantillaScreen() { const { recipes } = window.RC_STORE.useStore(); const MEAL_SLOTS = D.MEAL_SLOTS; const DAYS_ES = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']; const [template, setTemplate] = useState(null); const [loading, setLoading] = useState(true); const [applying, setApplying] = useState(false); const loadTemplate = useCallback(async () => { setLoading(true); try { const r = await window.api('/api/template'); setTemplate(r); } catch (e) { window.showToast(e.message, 'error'); } finally { setLoading(false); } }, []); useEffect(() => { loadTemplate(); }, []); const deleteTemplateRecipe = async (trid) => { try { await window.api(`/api/template/recipes/${trid}`, 'DELETE'); await loadTemplate(); } catch (e) { window.showToast(e.message, 'error'); } }; const openAddSlot = (dayIdx, mealType, dayLabel, slotLabel) => { window.rcOpen('template-slot', { dayIdx, mealType, dayLabel, slotLabel, onSave: loadTemplate }); }; const applyTemplate = async (onlyEmpty) => { setApplying(true); try { await window.api(`/api/template/apply${onlyEmpty ? '?only_empty=true' : ''}`, 'POST', {}); window.showToast('Plantilla aplicada al planning de esta semana'); } catch (e) { window.showToast(e.message, 'error'); } finally { setApplying(false); } }; const recipeMap = useMemo(() => { const m = {}; (recipes || []).forEach(r => { m[r.id] = r; }); return m; }, [recipes]); const filledSlots = (template || []).reduce((s, d) => s + Object.values(d.meals || {}).filter(m => (m.recipes || []).length > 0).length, 0); return ( <> } actions={ <> } />
{loading &&
Cargando plantilla…
} {!loading && (
{DAYS_ES.map(d => (
{d.slice(0, 3)}
))} {MEAL_SLOTS.map(slot => (
{slot.label}
{(template || []).map((day) => { const m = day.meals?.[slot.id] || { recipes: [] }; const recipesList = m.recipes || []; const hasRecipes = recipesList.length > 0; const tone = hasRecipes ? (recipeMap[recipesList[0]?.recipe_id]?.tone || 'mint') : null; return (
{recipesList.map(r => (
{r.raciones} rac
{r.recipe_name}
))}
); })}
))}
)} {!loading && (
CÓMO FUNCIONA
Define una semana tipo. Con "Aplicar a esta semana" se rellena el planning actual con estas recetas. "Solo huecos vacíos" no sobreescribe lo que ya tienes planificado.
)}
); } // ========================================================= // ARTÍCULOS — inventory // ========================================================= function ArticulosScreen() { const store = window.RC_STORE.useStore(); const { ingredients, reload } = store; const [filter, setFilter] = useState('Todos'); const [search, setSearch] = useState(''); const [editingStockId, setEditingStockId] = useState(null); const [editingStockVal, setEditingStockVal] = useState(''); const filters = ['Todos', 'Bajo mínimo', 'Marcados', 'Mercadona', 'Lidl', 'Carrefour']; const quickStock = async (a, delta) => { try { await window.api(`/api/ingredients/${a.id}/stock`, 'PATCH', { stock: (a.stock || 0) + delta }); await reload.ingredients(); } catch (e) { window.showToast(e.message, 'error'); } }; const startEditStock = (e, a) => { e.stopPropagation(); setEditingStockId(a.id); setEditingStockVal(String(a.stock ?? 0)); }; const commitEditStock = async (a) => { const val = parseFloat(editingStockVal); if (!isNaN(val) && val !== a.stock) { try { await window.api(`/api/ingredients/${a.id}/stock`, 'PATCH', { stock: val }); await reload.ingredients(); } catch (e) { window.showToast(e.message, 'error'); } } setEditingStockId(null); }; const toggleManualShop = async (a) => { if (a.manual_shop) { try { await window.api(`/api/ingredients/${a.id}/manual-shop`, 'PATCH', { manual_shop: false, extra_packs: 0 }); await reload.ingredients(); } catch (e) { window.showToast(e.message, 'error'); } } else { window.rcOpen('qty', { title: `¿Cuántos envases de ${a.name}?`, defaultValue: 1, onConfirm: async (qty) => { try { await window.api(`/api/ingredients/${a.id}/manual-shop`, 'PATCH', { manual_shop: true, extra_packs: Math.max(0, qty - 1) }); await reload.ingredients(); } catch (e) { window.showToast(e.message, 'error'); } }, }); } }; const deleteArticle = async (aid) => { try { await window.api(`/api/ingredients/${aid}`, 'DELETE'); await reload.ingredients(); window.showToast('Artículo eliminado'); } catch (e) { window.showToast(e.message, 'error'); } }; const data = (ingredients || []).filter(a => { if (search && !(a.name || '').toLowerCase().includes(search.toLowerCase())) return false; if (filter === 'Todos') return true; if (filter === 'Bajo mínimo') return a.stock < a.min_stock; if (filter === 'Marcados') return a.manual_shop; return (a.supermarket || '').toLowerCase() === filter.toLowerCase(); }); const lowCount = (ingredients || []).filter(a => a.stock < a.min_stock).length; const markedCount = (ingredients || []).filter(a => a.manual_shop).length; const openArticle = (a) => window.rcOpen('article', { article: a, onSave: reload.ingredients }); return ( <> 0 ? `${lowCount} → COMPRA` : null} /> } actions={<>} />
{filters.map(f => ( ))} setSearch(e.target.value)} style={{ marginLeft: 'auto', width: 200, padding: '4px 10px', fontSize: 13 }} />
{data.map(a => { const low = a.stock < a.min_stock; return ( openArticle(a)} style={{ cursor: 'pointer' }}> ); })} {data.length === 0 && (ingredients || []).length > 0 && ( )} {(ingredients || []).length === 0 && ( )}
Artículo Supermercado Unidad Envase Stock Mín
{a.name} {!a.canonical_name && ( SIN PRECIO )}
{(a.supermarket || '').toUpperCase()} {a.unit} {a.envase_qty} {a.unit} ev.stopPropagation()}> {editingStockId === a.id ? ( setEditingStockVal(e.target.value)} onBlur={() => commitEditStock(a)} onKeyDown={e => { if (e.key === 'Enter') commitEditStock(a); if (e.key === 'Escape') setEditingStockId(null); }} style={{ width: 56, textAlign: 'right', background: 'var(--slate)', border: '1px solid var(--mint)', borderRadius: 4, color: 'var(--text)', fontFamily: 'var(--f-display)', fontSize: 18, padding: '2px 4px' }} /> ) : ( startEditStock(e, a)} >{a.stock} )} {a.min_stock} ev.stopPropagation()}>
Sin artículos con ese filtro.
Sin artículos. Crea el primero.
); } // ========================================================= // ENTRADAS — expense list with month navigation // ========================================================= function EntradasScreen() { const { reload: storeReload } = window.RC_STORE.useStore(); const [monthOffset, setMonthOffset] = useState(0); // 0 = current month const [expenses, setExpenses] = useState([]); const [loading, setLoading] = useState(true); const getMonthKey = (offset) => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() + offset); return d.toISOString().slice(0, 7); // YYYY-MM }; const monthKey = getMonthKey(monthOffset); const monthLabel = useMemo(() => { const [y, m] = monthKey.split('-').map(Number); return new Date(y, m - 1, 1).toLocaleString('es-ES', { month: 'long', year: 'numeric' }); }, [monthKey]); const loadExpenses = useCallback(async (mk) => { setLoading(true); try { const r = await window.api(`/api/expenses?month=${mk}`); setExpenses(r || []); } catch (e) { window.showToast(e.message, 'error'); } finally { setLoading(false); } }, []); useEffect(() => { loadExpenses(monthKey); }, [monthKey]); const total = expenses.reduce((s, e) => s + (e.amount || 0), 0); const openExpense = (e) => { window.dispatchEvent(new CustomEvent('rc:modal', { detail: { name: 'expense', props: { expense: e, onSave: () => loadExpenses(monthKey) } }, })); }; const deleteExpense = async (id) => { try { await window.api(`/api/expenses/${id}`, 'DELETE'); setExpenses(prev => prev.filter(e => e.id !== id)); await storeReload.expenses(); window.showToast('Gasto eliminado'); } catch (e) { window.showToast(e.message, 'error'); } }; return ( <> } actions={null} />
{monthLabel.charAt(0).toUpperCase() + monthLabel.slice(1)}
{loading &&
Cargando…
} {!loading && (
{expenses.map((e) => ( openExpense(e)} style={{ cursor: 'pointer' }}> ))} {expenses.length === 0 && ( )}
Fecha Lugar Categoría Pago Notas Importe
{(e.expense_date || '').toUpperCase()}
{e.place_name || '—'}
{e.category_icon && {e.category_icon}} {e.category_name || '—'} {(e.payment || '').toUpperCase()} {e.details || ''} {(e.amount || 0).toFixed(2)}€ ev.stopPropagation()}>
{e.matching_ticket_id ? ( ) : ( )}
Sin gastos en {monthLabel}.
TOTAL {total.toFixed(2)}€
)}
); } // ========================================================= // MESES — year view // ========================================================= const MONTH_NAMES_ES = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; function MesesScreen() { const { categories } = window.RC_STORE.useStore(); const [apiData, setApiData] = useState(null); const [year, setYear] = useState(new Date().getFullYear().toString()); useEffect(() => { window.api(`/api/expenses/months?year=${year}`) .then(r => setApiData(r)) .catch(e => window.showToast(e.message, 'error')); }, [year]); const currentYM = new Date().toISOString().slice(0, 7); const rawMonths = (apiData?.months || []); const years = apiData?.years || [year]; const yearTotal = rawMonths.reduce((s, m) => s + m.total, 0); const realMonths = rawMonths.filter(m => m.total > 0); const avg = realMonths.length > 0 ? yearTotal / realMonths.length : 0; const catColorMap = useMemo(() => { const m = {}; (categories || []).forEach((c, i) => { m[c.name] = CAT_PALETTE[i % CAT_PALETTE.length]; }); return m; }, [categories]); // Item 15: descending order, no future months const allMonths = useMemo(() => { const result = []; for (let idx = 11; idx >= 0; idx--) { const mk = `${year}-${String(idx + 1).padStart(2, '0')}`; if (mk > currentYM) continue; const m = rawMonths.find(x => x.month === mk); result.push({ mes: MONTH_NAMES_ES[idx], mk, total: m?.total || 0, top_categories: m?.top_categories || [], topCat: m?.top_categories?.[0]?.name || '—', topPlace: m?.top_places?.[0]?.name || '—', current: mk === currentYM, }); } return result; }, [rawMonths, year, currentYM]); const max = Math.max(...allMonths.map(m => m.total), 1); return ( <> } actions={ <> {[...years].reverse().map(y => ( ))} } />
{allMonths.map(m => (
{/* Item 16: stacked bars by category */}
{m.top_categories.length > 0 ? m.top_categories.map((cat, ci) => (
0 ? ((cat.total / m.total) * 100).toFixed(1) : 0}%`, background: catColorMap[cat.name] || CAT_PALETTE[ci % CAT_PALETTE.length], minHeight: m.total > 0 ? 2 : 0, }} /> )) :
} {m.total > 0 && ( {m.total.toFixed(0)}€ )}
{m.mes}
))}
{allMonths.map((m, idx) => { const CARD_PALETTE = [ { bg: '#3cffd0', fg: '#000' }, { bg: '#5200ff', fg: '#fff' }, { bg: '#ffd166', fg: '#000' }, { bg: '#ff7ad9', fg: '#000' }, { bg: '#ff6a1a', fg: '#000' }, ]; const pal = CARD_PALETTE[idx % CARD_PALETTE.length]; if (m.total === 0 && !m.current) return (
SIN DATOS
{m.mes}
); const metaColor = pal.fg === '#fff' ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.55)'; return (
{m.current ? 'MES ACTUAL' : 'CERRADO'}
{m.mes} {m.total.toFixed(0)}€

TOP CATEGORÍA {m.topCat}
TOP LUGAR {m.topPlace}
); })}
); } // ========================================================= // ALIASES — product aliases // ========================================================= function NewAliasModal({ onClose, onSaved }) { const [rawName, setRawName] = useState(''); const [canonicalName, setCanonicalName] = useState(''); const [saving, setSaving] = useState(false); const doSave = async () => { if (!rawName.trim() || !canonicalName.trim()) { window.showToast('Rellena ambos campos', 'error'); return; } setSaving(true); try { await window.api('/api/ticket-aliases', 'POST', { raw_name: rawName.trim(), canonical_name: canonicalName.trim(), }); window.showToast('Alias creado'); onSaved(); onClose(); } catch (e) { window.showToast(e.message, 'error'); } finally { setSaving(false); } }; return (
Nuevo alias
setRawName(e.target.value)} placeholder="ej. POLLO FRESCO 1KG" style={{ width: '100%' }} />
setCanonicalName(e.target.value)} placeholder="ej. pechuga de pollo" style={{ width: '100%' }} />
); } function AliasesScreen() { const auth = window.RC_Auth?.useAuth?.() ?? null; const isAdmin = auth?.user?.is_admin ?? false; const [filter, setFilter] = useState('Todos'); const [aliases, setAliases] = useState([]); const [loading, setLoading] = useState(true); const [showNewModal, setShowNewModal] = useState(false); const [editingId, setEditingId] = useState(null); const [editingVal, setEditingVal] = useState(''); const [allHouseholds, setAllHouseholds] = useState(false); const filters = ['Todos', 'Canonical', 'Propuestos']; // Merge suggestions state const [suggestions, setSuggestions] = useState([]); const [suggLoading, setSuggLoading] = useState(false); const [suggLoaded, setSuggLoaded] = useState(false); const [survivorMap, setSurvivorMap] = useState({}); const loadAliases = useCallback(async () => { setLoading(true); try { const url = allHouseholds ? '/api/ticket-aliases?all_households=true&limit=500' : '/api/ticket-aliases'; const r = await window.api(url); setAliases(r || []); } catch (e) { window.showToast(e.message, 'error'); } finally { setLoading(false); } }, [allHouseholds]); const loadSuggestions = async () => { setSuggLoading(true); try { const r = await window.api('/api/admin/merge-suggestions?type=alias'); setSuggestions(r || []); const m = {}; (r || []).forEach((g, i) => { m[i] = g.suggested_survivor_id; }); setSurvivorMap(m); setSuggLoaded(true); } catch (e) { window.showToast(e.message, 'error'); } finally { setSuggLoading(false); } }; const doMerge = async (groupIdx, group) => { const survivorId = survivorMap[groupIdx] ?? group.suggested_survivor_id; const loserIds = group.members.filter(m => m.id !== survivorId).map(m => m.id); if (loserIds.length === 0) { window.showToast('Selecciona un superviviente diferente', 'error'); return; } window.showConfirm( `¿Fusionar ${group.members.length} aliases en uno? Esta acción no se puede deshacer.`, async () => { window.closeConfirm(); try { await window.api('/api/admin/merge', 'POST', { type: 'alias', survivor_id: survivorId, loser_ids: loserIds }); window.showToast(`Fusionados ${loserIds.length} aliases`); await loadAliases(); await loadSuggestions(); } catch (e) { window.showToast(e.message, 'error'); } } ); }; useEffect(() => { loadAliases(); }, [loadAliases]); const data = aliases.filter(a => { if (filter === 'Canonical') return a.status === 'canonical'; if (filter === 'Propuestos') return a.status === 'proposed'; return true; }); const counts = { canonical: aliases.filter(a => a.status === 'canonical').length, proposed: aliases.filter(a => a.status === 'proposed').length, }; const doConfirm = async (id) => { try { await window.api(`/api/ticket-aliases/${id}/confirm`, 'POST', {}); await loadAliases(); window.showToast('Alias confirmado'); } catch (e) { window.showToast(e.message, 'error'); } }; const doPromote = async (id) => { try { await window.api(`/api/admin/aliases/${id}/promote`, 'POST', {}); await loadAliases(); window.showToast('Alias promovido a canonical'); } catch (e) { window.showToast(e.message, 'error'); } }; const doRevert = async (id) => { try { await window.api(`/api/admin/aliases/${id}/revert`, 'POST', {}); await loadAliases(); window.showToast('Alias revertido a propuesto'); } catch (e) { window.showToast(e.message, 'error'); } }; const startEdit = (a) => { setEditingId(a.id); setEditingVal(a.canonical_name); }; const cancelEdit = () => setEditingId(null); const commitEdit = async (a) => { if (!editingVal.trim()) { window.showToast('El canónico no puede estar vacío', 'error'); return; } try { await window.api(`/api/ticket-aliases/${a.id}`, 'PUT', { canonical_name: editingVal.trim() }); await loadAliases(); setEditingId(null); window.showToast('Alias actualizado'); } catch (e) { window.showToast(e.message, 'error'); } }; return ( <> {showNewModal && ( setShowNewModal(false)} onSaved={loadAliases} /> )} 0 ? `${counts.proposed} → REVISIÓN` : null} /> } actions={ <> {isAdmin && ( )} } />
{filters.map(f => ( ))}
{loading &&
Cargando aliases…
} {!loading && (
{allHouseholds && } {data.map(a => ( {allHouseholds && ( )} ))} {data.length === 0 && ( )}
Texto del ticket Canónico SupermercadoHogarEstado
{a.raw_name} {editingId === a.id ? (
setEditingVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commitEdit(a); if (e.key === 'Escape') cancelEdit(); }} style={{ width: 160, padding: '2px 6px', fontSize: 13 }} />
) : ( {a.canonical_name} )}
{(a.supermarket_name || '—').toUpperCase()}{(a.proposing_household_name || '—').toUpperCase()} {a.status === 'canonical' ? CANONICAL : PROPUESTO}
{a.status === 'proposed' ? ( <> ) : ( )}
Sin aliases.
)} {/* ── Sugerencias de fusión ── */}
SUGERENCIAS DE FUSIÓN
{suggLoaded && suggestions.length === 0 && (
No se detectaron duplicados similares.
)} {suggestions.map((group, gIdx) => (
{group.members.map(m => { const isSurvivor = (survivorMap[gIdx] ?? group.suggested_survivor_id) === m.id; return ( ); })}
Texto del ticket Canónico Supermercado Estado
setSurvivorMap(prev => ({ ...prev, [gIdx]: m.id }))} /> {m.raw_name} {m.canonical_name} {(m.supermarket_name || '—').toUpperCase()} {m.status === 'canonical' ? CANONICAL : PROPUESTO}
))}
CÓMO FUNCIONA
3 hogares = canonical
Cualquier hogar puede proponer un alias. Cuando 3 hogares distintos confirman la misma equivalencia, el alias se promueve automáticamente a canonical. El admin puede promover o revertir manualmente.
); } // ========================================================= // CATEGORÍAS Y LUGARES (items 20/21/22 — split into two screens) // ========================================================= const CAT_PALETTE = ['#3cffd0','#5200ff','#ffd166','#ef476f','#06d6a0','#118ab2','#ff9f1c','#e63946']; function CategoriasScreen() { const { categories, budgets, reload } = window.RC_STORE.useStore(); const budgetByName = useMemo(() => { const m = {}; budgets.forEach(b => { m[b.name] = b; }); return m; }, [budgets]); const totalBudget = budgets.reduce((s, b) => s + (b.monthly_limit || 0), 0); const onSave = async () => { await reload.expenses(); await reload.static(); }; return ( <> window.rcOpen('category', { onSave })}>+ Nueva categoría } />
{categories.map((c, i) => { const color = CAT_PALETTE[i % CAT_PALETTE.length]; const bud = budgetByName[c.name]; return (
window.rcOpen('category', { category: { ...c, monthly_limit: bud?.monthly_limit }, onSave })} >
{c.icon || '📦'}
{c.name}
CATEGORÍA · #{c.id}

PRESUPUESTO MENSUAL
{(bud?.monthly_limit || 0).toFixed(0)}€
); })} {categories.length === 0 &&
Sin categorías.
}
); } function LugaresScreen() { const { places, categories, reload } = window.RC_STORE.useStore(); const auth = window.RC_Auth?.useAuth?.() ?? null; const isAdmin = auth?.user?.is_admin ?? false; const onSave = async () => { await reload.static(); }; const [allHouseholds, setAllHouseholds] = useState(false); const [allPlaces, setAllPlaces] = useState([]); const [allLoading, setAllLoading] = useState(false); const loadAllPlaces = useCallback(async () => { setAllLoading(true); try { const r = await window.api('/api/admin/places'); setAllPlaces(r || []); } catch (e) { window.showToast(e.message, 'error'); } finally { setAllLoading(false); } }, []); useEffect(() => { if (allHouseholds) loadAllPlaces(); else setAllPlaces([]); }, [allHouseholds, loadAllPlaces]); // Merge suggestions state const [suggestions, setSuggestions] = useState([]); const [suggLoading, setSuggLoading] = useState(false); const [suggLoaded, setSuggLoaded] = useState(false); const [survivorMap, setSurvivorMap] = useState({}); const loadSuggestions = async () => { setSuggLoading(true); try { const r = await window.api('/api/admin/merge-suggestions?type=place'); setSuggestions(r || []); const m = {}; (r || []).forEach((g, i) => { m[i] = g.suggested_survivor_id; }); setSurvivorMap(m); setSuggLoaded(true); } catch (e) { window.showToast(e.message, 'error'); } finally { setSuggLoading(false); } }; const doMerge = async (groupIdx, group) => { const survivorId = survivorMap[groupIdx] ?? group.suggested_survivor_id; const loserIds = group.members.filter(m => m.id !== survivorId).map(m => m.id); if (loserIds.length === 0) { window.showToast('Selecciona un superviviente diferente', 'error'); return; } window.showConfirm( `¿Fusionar ${group.members.length} lugares en uno? Esta acción no se puede deshacer.`, async () => { window.closeConfirm(); try { await window.api('/api/admin/merge', 'POST', { type: 'place', survivor_id: survivorId, loser_ids: loserIds }); window.showToast(`Fusionados ${loserIds.length} lugares`); await reload.static(); await loadSuggestions(); } catch (e) { window.showToast(e.message, 'error'); } } ); }; return ( <> {isAdmin && ( )} } />
{allLoading &&
Cargando todos los lugares…
} {!allLoading && (
{allHouseholds && } {!allHouseholds && } {allHouseholds ? allPlaces.map(p => ( )) : places.map(p => ( ))} {!allHouseholds && places.length === 0 && ( )}
LugarHogarCategoría por defecto Pago por defecto
{p.name} {(p.household_name || '—').toUpperCase()} {p.category_name || '—'} {(p.default_payment || '—').toUpperCase()}
{p.name} {p.category_name || '—'} {(p.default_payment || '—').toUpperCase()}
Sin lugares.
)} {/* ── Sugerencias de fusión ── */}
SUGERENCIAS DE FUSIÓN
{suggLoaded && suggestions.length === 0 && (
No se detectaron duplicados similares.
)} {suggestions.map((group, gIdx) => (
{group.members.map(m => { const isSurvivor = (survivorMap[gIdx] ?? group.suggested_survivor_id) === m.id; return ( ); })}
Nombre del lugar Hogar
setSurvivorMap(prev => ({ ...prev, [gIdx]: m.id }))} /> {m.name} {(m.household_name || '—').toUpperCase()}
))}
); } // ========================================================= // UNIDADES — product unit mappings // ========================================================= function UnidadesScreen() { const [unitMaps, setUnitMaps] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { window.api('/api/product-unit-mappings') .then(r => setUnitMaps(r || [])) .catch(e => window.showToast(e.message, 'error')) .finally(() => setLoading(false)); }, []); return ( <> } actions={} />
EJEMPLO
1L aceite = 1.000 ml
Sin este mapeo, al subir un ticket con "1L AOVE" el stock no se descontaría correctamente al ejecutar una receta que pide "20ml de aceite".
{loading &&
Cargando mapeos…
} {!loading && (
{unitMaps.map(m => ( ))} {unitMaps.length === 0 && ( )}
Producto Unidad en ticket Pack qty Unidad en receta
{m.product_name} {m.ticket_unit} × {m.pack_qty} {m.recipe_unit}
Sin mapeos.
)}
); } // ========================================================= // INVITACIONES — admin // ========================================================= function InvitesScreen() { const [invites, setInvites] = useState([]); const [gemini, setGemini] = useState(null); const [invEmail, setInvEmail] = useState(''); const [invHours, setInvHours] = useState(72); const [busy, setBusy] = useState(false); const loadData = useCallback(async () => { try { const [invs, gem] = await Promise.all([ window.api('/api/admin/invites'), window.api('/api/admin/gemini-usage/summary'), ]); setInvites(invs || []); setGemini(gem || null); } catch (e) { window.showToast(e.message, 'error'); } }, []); useEffect(() => { loadData(); }, []); const inviteStatus = (inv) => { const now = new Date(); if (inv.used_at) return 'used'; if (new Date(inv.expires_at) < now) return 'expired'; return 'active'; }; const doCreate = async () => { setBusy(true); try { const r = await window.api('/api/admin/invites', 'POST', { email: invEmail.trim() || null, hours: invHours, new_household: false, }); window.showToast(`Invitación creada: ${r.code}`); setInvEmail(''); await loadData(); } catch (e) { window.showToast(e.message, 'error'); } finally { setBusy(false); } }; const copyLink = (code) => { const url = `${window.location.origin}/#invite=${code}`; navigator.clipboard.writeText(url).then(() => window.showToast('Link copiado')); }; const counts = { active: invites.filter(i => inviteStatus(i) === 'active').length, used: invites.filter(i => inviteStatus(i) === 'used').length, expired: invites.filter(i => inviteStatus(i) === 'expired').length, }; const g = gemini; return ( <> 0 ? `${counts.active} CÓDIGOS` : null} /> 0} delta={counts.expired > 0 ? `${counts.expired} EXPIRADAS` : null} /> } actions={} />
{invites.map(inv => { const status = inviteStatus(inv); return ( ); })} {invites.length === 0 && ( )}
Código Email Creada Caduca Estado
{inv.id.slice(0, 12)}… {inv.email || '—'} {(inv.created_at || '').slice(0, 10).toUpperCase()} {(inv.expires_at || '').slice(0, 10).toUpperCase()} {status === 'active' && ACTIVA} {status === 'used' && USADA} {status === 'expired' && CADUCADA} {status === 'active' && ( )}
Sin invitaciones.
NUEVA INVITACIÓN
Crear código
setInvEmail(e.target.value)} placeholder="usuario@example.com" style={{ background: 'rgba(0,0,0,0.2)', borderColor: 'rgba(255,255,255,0.25)', color: '#fff' }} />
setInvHours(+e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', borderColor: 'rgba(255,255,255,0.25)', color: '#fff' }} />
{g && (
USO DE GEMINI · {g.month}
{(g.total_estimated_eur || 0).toFixed(2)}€
ESTIMADO ESTE MES

LLAMADAS{g.total_calls}
TOKENS IN{(g.total_input_tokens || 0).toLocaleString('es-ES')}
TOKENS OUT{(g.total_output_tokens || 0).toLocaleString('es-ES')}
HOGARES{(g.by_household || []).length}
{(g.by_household || []).length > 0 && (
POR HOGAR
{g.by_household.map(h => (
{h.household_name || 'Hogar ' + h.household_id} {(h.estimated_eur || 0).toFixed(4)}€
{h.calls} llamadas · {(h.input_tokens || 0).toLocaleString('es-ES')} in · {(h.output_tokens || 0).toLocaleString('es-ES')} out
))}
)}
)}
); } // ========================================================= // DASHBOARD — admin usage analytics // ========================================================= function DashboardScreen() { const [data, setData] = useState(null); const [days, setDays] = useState(30); const [loading, setLoading] = useState(true); const loadData = useCallback(async (d) => { setLoading(true); try { const r = await window.api(`/api/admin/usage-dashboard?days=${d}`); setData(r); } catch (e) { window.showToast(e.message, 'error'); } finally { setLoading(false); } }, []); useEffect(() => { loadData(days); }, [days]); if (loading && !data) return
Cargando…
; const t = data?.totals || {}; const lbd = data?.logins_by_day || []; const lbh = data?.logins_by_hour || []; const rl = data?.recent_logins || []; const bh = data?.by_household || []; const tkt = data?.tickets || { by_day: [], queue: {} }; const lbdMax = Math.max(1, ...lbd.map(d => d.count)); const lbhMax = Math.max(1, ...lbh.map(d => d.count)); const tbdMax = Math.max(1, ...(tkt.by_day || []).map(d => d.count)); const RangeBtn = ({ d }) => ( ); return ( <> } actions={<> {loading && Actualizando…} } />
ACCESOS
{t.logins_period || 0}
ÚLTIMOS {days} DÍAS
USUARIOS ACTIVOS
{t.active_users_period || 0}
DISTINTOS EN PERIODO
INGREDIENTES
{t.ingredients || 0}
TOTAL GLOBAL
COLA OCR
{(tkt.queue.pending || 0) + (tkt.queue.processing || 0)}
{tkt.queue.error || 0} CON ERROR
{lbd.length === 0 ?
Sin datos en el rango
: lbd.map(d => (
{d.count}
{d.day.slice(5)}
))}
{lbh.length === 0 ?
Sin datos en el rango
: lbh.map(d => (
{d.count}
{String(d.hour).padStart(2, '0')}
))}
{rl.length === 0 && } {rl.map((r, i) => ( ))}
EmailHogarFecha
Sin datos
{r.email} {r.household_name || String(r.household_id)} {r.created_at?.slice(0, 16).replace('T', ' ')}
{bh.length === 0 && } {bh.map(h => ( ))}
Hogar Usuarios Recetas Ingred. Tickets Accesos Último acceso Gemini €
Sin datos
{h.household_name || '—'} {h.users} {h.recipes} {h.ingredients} {h.tickets} {h.logins_period} {h.last_login_at?.slice(0, 16) || '—'} {(h.gemini_eur || 0).toFixed(4)}€
{tkt.by_day.length === 0 ?
Sin datos en el rango
: tkt.by_day.map(d => (
{d.count}
{d.day.slice(5)}
))}
{Object.entries(tkt.queue).map(([status, count]) => (
{status.toUpperCase()} {count}
))}
); } // ========================================================= // CUENTAS DE PAGO // ========================================================= function CuentasPagoScreen() { const { paymentAccounts, reload } = window.RC_STORE.useStore(); const onSave = async () => { await reload.static(); }; return ( <> window.rcOpen('payment-account', { onSave })}>+ Nueva cuenta } />
{paymentAccounts.map(pa => ( ))} {paymentAccounts.length === 0 && ( )}
Nombre
{pa.name}
Sin cuentas.
); } Object.assign(window.RC_SCREENS, { plantilla: () => , articulos: () => , entradas: () => , meses: () => , aliases: () => , categorias: () => , lugares: () => , unidades: () => , invites: () => , dashboard: () => , cuentaspago: () => , });