// Data store — fetches live data from /api/* and provides it via React context const { useState, useEffect, useCallback, createContext, useContext } = React; const StoreCtx = createContext(null); // ── Palette helpers ─────────────────────────────────────────────────────────── const RECIPE_TONES = ['mint', 'yellow', 'pink', 'orange', 'purple', 'blue']; const CAT_COLORS = ['#3cffd0', '#5200ff', '#ffd166', '#ef476f', '#06d6a0', '#118ab2', '#ff9f1c', '#e63946']; const DOW_SHORT = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; // ISO weekday 0=Lun function toneForId(id) { return RECIPE_TONES[id % RECIPE_TONES.length]; } function colorForIdx(i) { return CAT_COLORS[i % CAT_COLORS.length]; } // ── Mapping helpers ─────────────────────────────────────────────────────────── function mapRecipes(raws) { return (raws || []).map(r => ({ ...r, kinds: r.meal_types || [], tone: toneForId(r.id), public: r.is_public, raciones: 2, })); } function mapPlanningWeek(apiData, recipeList) { if (!apiData) return null; const todayISO = new Date().toISOString().slice(0, 10); const SLOT_IDS = ['desayuno', 'comida', 'merienda', 'cena']; const days = (apiData.days || []).map(d => { const dateObj = new Date(d.date + 'T12:00:00'); const dow = DOW_SHORT[(dateObj.getDay() + 6) % 7]; const dom = dateObj.getDate(); return { date: d.date, dow, dom, today: d.date === todayISO }; }); const planning = (apiData.days || []).map(d => { return SLOT_IDS.map(sid => { const m = d.meals?.[sid]; const recipes = (m?.recipes || []).map(r => ({ id: r.id, recipeId: r.recipe_id, title: r.recipe_name || '(sin nombre)', raciones: r.raciones || 1, notConsumed: Boolean(r.not_consumed), })); const loose = (m?.loose || []).map(l => ({ id: l.id, name: l.ingredient_name, qty: l.qty, unit: l.unit, })); if (recipes.length === 0 && loose.length === 0) return null; return { recipes, loose, looseCost: m?.loose_cost || 0, _date: d.date, _slotId: sid, }; }); }); return { days, planning, weekStart: apiData.week_start, weekLabel: apiData.label }; } function mapExpenseSummary(summary, budgets, prevMonthTotal) { if (!summary) return null; const byCat = {}; (summary.by_category || []).forEach(c => { byCat[c.name] = c; }); const mappedBudgets = (budgets || []).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: round2(spent), limit: round2(limit), pct, over: limit > 0 && spent > limit, color: colorForIdx(i), }; }); const totalMonth = summary.total || 0; const topCat = (summary.by_category || [])[0] || { name: '—', total: 0 }; const budget = mappedBudgets.reduce((s, b) => s + b.limit, 0); const vsLast = prevMonthTotal && prevMonthTotal > 0 ? Math.round(((totalMonth - prevMonthTotal) / prevMonthTotal) * 100) : 0; const kpis = { totalMonth, budget, vsLast: (vsLast >= 0 ? '+' : '') + vsLast, topCategory: topCat.name, topCategoryAmount: topCat.total, ticketAvg: 0, ticketsCount: 0, }; const weekBars = (summary.evolution || []).map((e, i) => ({ lbl: `S${i + 1}`, v: e.total, c: i % 2 === 0 ? 'col--mint' : 'col--ultra', })); const topPlaces = (summary.top_places || []).map(p => ({ name: p.name, v: p.total })); const byPayment = summary.by_payment || {}; const paymentEntries = Object.entries(byPayment).sort((a, b) => b[1] - a[1]); return { budgets: mappedBudgets, kpis, weekBars, topPlaces, byPayment, paymentEntries }; } function round2(n) { return Math.round((n || 0) * 100) / 100; } // ── Store provider ──────────────────────────────────────────────────────────── function StoreProvider({ children }) { const [recipes, setRecipes] = useState([]); const [categories, setCategories] = useState([]); const [places, setPlaces] = useState([]); const [paymentAccounts, setPaymentAccounts] = useState([]); const [budgets, setBudgets] = useState([]); const [planningWeek, setPlanningWeek] = useState(null); const [weekOffset, setWeekOffset] = useState(0); // 0 = current, +/-1 = next/prev const [expenseSummary, setExpenseSummary] = useState(null); const [expenses, setExpenses] = useState([]); const [articles, setArticles] = useState([]); const [notifications, setNotifications] = useState([]); const [ingredients, setIngredients] = useState([]); const [recipeCosts, setRecipeCosts] = useState({}); const [loaded, setLoaded] = useState(false); // Compute monday of the displayed week function getWeekMonday(offset) { const today = new Date(); const dow = (today.getDay() + 6) % 7; // 0=Mon const monday = new Date(today); monday.setDate(today.getDate() - dow + offset * 7); return monday.toISOString().slice(0, 10); } const loadRecipes = useCallback(async () => { const r = await window.api('/api/recipes'); setRecipes(mapRecipes(r)); }, []); const loadPlanning = useCallback(async (offset) => { const monday = getWeekMonday(offset); const data = await window.api(`/api/planning?week=${monday}`); setPlanningWeek(mapPlanningWeek(data, [])); }, []); const loadExpenses = useCallback(async () => { const now = new Date(); const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevRef = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}-01`; const [summary, expList, bud, prevSummary] = await Promise.all([ window.api(`/api/expenses/summary?period=month`), window.api('/api/expenses'), window.api('/api/expense-budgets'), window.api(`/api/expenses/summary?period=month&date=${prevRef}`), ]); setBudgets(bud || []); setExpenses(expList || []); setExpenseSummary(mapExpenseSummary(summary, bud || [], prevSummary?.total || 0)); }, []); const loadArticles = useCallback(async () => { const r = await window.api('/api/articles'); setArticles(r || []); }, []); const loadNotifications = useCallback(async () => { const r = await window.api('/api/notifications'); setNotifications(r || []); }, []); const loadStatic = useCallback(async () => { const [cats, pl, pa] = await Promise.all([ window.api('/api/expense-categories'), window.api('/api/expense-places'), window.api('/api/payment-accounts'), ]); setCategories(cats || []); setPlaces(pl || []); setPaymentAccounts(pa || []); }, []); const loadIngredients = useCallback(async () => { const r = await window.api('/api/ingredients'); setIngredients(r || []); }, []); const loadRecipeCosts = useCallback(async () => { const r = await window.api('/api/recipes/costs'); setRecipeCosts(r || {}); }, []); const loadAll = useCallback(async () => { try { await Promise.all([loadRecipes(), loadPlanning(0), loadExpenses(), loadStatic(), loadArticles(), loadNotifications(), loadIngredients(), loadRecipeCosts()]); setLoaded(true); } catch (e) { window.showToast('Error cargando datos: ' + e.message, 'error'); } }, [loadRecipes, loadPlanning, loadExpenses, loadStatic, loadArticles, loadNotifications, loadIngredients, loadRecipeCosts]); const changeWeek = useCallback(async (newOffset) => { setWeekOffset(newOffset); try { await loadPlanning(newOffset); } catch (e) { window.showToast('Error cargando semana: ' + e.message, 'error'); } }, [loadPlanning]); useEffect(() => { loadAll(); }, []); const ctx = { recipes, categories, places, budgets, paymentAccounts, planningWeek, weekOffset, changeWeek, expenseSummary, expenses, articles, notifications, ingredients, recipeCosts, loaded, reload: { recipes: loadRecipes, planning: () => loadPlanning(weekOffset), expenses: loadExpenses, articles: loadArticles, notifications: loadNotifications, ingredients: loadIngredients, recipeCosts: loadRecipeCosts, static: loadStatic, all: loadAll, }, }; return {children}; } function useStore() { return useContext(StoreCtx); } window.RC_STORE = { StoreProvider, useStore }; // Deriva texto visible desde type+payload (BD usa "read" 0/1, no "read_at") window.RC_notifText = function(n) { const p = n.payload || {}; if (n.type === 'ticket_ready') { const parts = []; if (p.supermarket) parts.push(p.supermarket); if (p.total != null) parts.push(p.total + '€'); if (p.date) parts.push(p.date); const dup = p.duplicate_ticket_id ? ' · Posible duplicado' : ''; return { title: 'Ticket listo para verificar', body: parts.join(' · ') + dup, queueItemId: p.queue_item_id || null, }; } if (n.type === 'ticket_error') { const body = (p.reason === 'quota' || p.reason === 'transient') ? 'Procesamiento en pausa, reintentaremos pronto' : (p.message || 'Error procesando el ticket'); return { title: 'Error al procesar ticket', body, queueItemId: null }; } return { title: n.type || 'Notificación', body: '', queueItemId: null }; }; // Aplana ticket_data anidado (forma real del worker) a campos editables planos. window.RC_ticketFlat = function (td) { td = td || {}; return { supermarket: td.supermarket?.name || '', date: td.ticket_info?.date || '', total: td.totals?.total ?? null, items: (td.items || []).map(it => ({ product_name: it.product_name || '', quantity: it.quantity ?? 1, unit: it.unit || 'ud', unit_price: it.unit_price ?? null, total_price: it.total_price ?? null, })), }; }; // Reconstruye la forma anidada que exige TicketData.model_validate. // rawTd = ticket_data original del servidor; flat = estado editado (salida de RC_ticketFlat). window.RC_ticketBuild = function (rawTd, flat) { rawTd = rawTd || {}; const items = (flat.items || []).map(it => { const qty = Number(it.quantity) || 0; const up = (it.unit_price === '' || it.unit_price == null) ? null : Number(it.unit_price); const tp = (it.total_price === '' || it.total_price == null) ? +((up || 0) * qty).toFixed(2) : Number(it.total_price); return { product_name: it.product_name || '', quantity: qty, unit: it.unit || 'ud', unit_price: up, total_price: tp, }; }); return { ...rawTd, supermarket: { ...(rawTd.supermarket || {}), name: flat.supermarket || '' }, ticket_info: { ...(rawTd.ticket_info || {}), date: flat.date || null }, items, totals: { ...(rawTd.totals || {}), total: (flat.total === '' || flat.total == null) ? null : Number(flat.total) }, }; };