// screen-budget-goals.jsx — Budget & Tabungan/Goals // ─── BUDGET ────────────────────────────────────────────────────── function ScreenBudget({ theme, accent, scenario, auth }) { const [realBudgets, setRealBudgets] = React.useState(null); const familyId = auth && auth.family && auth.family.id; React.useEffect(function() { if (!familyId || !window.sbGetBudgets) return; sbGetBudgets(familyId).then(function(r){ if(!r.error) setRealBudgets(r.data||[]); }); }, [familyId]); const isAuth = !!(auth && auth.family); // Normalize budget shape: real data (budget_usage view) vs dummy function normBudget(b) { if (b.amount_limit !== undefined) { // Real data from budget_usage view return { id: b.budget_id, cat: (b.category_name||'').toLowerCase().replace(/\s+/g,'_') || 'lainnya_ex', catName: b.category_name || 'Lainnya', catColor: b.category_color || '#6366f1', limit: b.amount_limit, used: b.used_amount || 0, pct: b.used_pct || 0, isReal: true, }; } return Object.assign({}, b, {catName: (CATEGORIES[b.cat]||{}).label||b.cat, catColor:(CATEGORIES[b.cat]||{}).color||'#6366f1', isReal:false}); } const budgets = (isAuth ? (realBudgets||[]) : scenario.budgets).map(normBudget); const totalLimit = budgets.reduce((a, b) => a + b.limit, 0); const totalUsed = budgets.reduce((a, b) => a + b.used, 0); const remaining = totalLimit - totalUsed; const pct = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0; const [showAdd, setShowAdd] = React.useState(false); const [addCat, setAddCat] = React.useState(''); const [addLimit, setAddLimit] = React.useState(''); const [saving, setSaving] = React.useState(false); async function handleAddBudget() { if (!addCat || !addLimit) return; setSaving(true); var amount = parseInt(addLimit.replace(/\D/g,''), 10); if (window.sbAddBudget && auth && auth.family) { await sbAddBudget(auth.family.id, addCat, amount, 80); if (window.sbGetBudgets) { var r = await sbGetBudgets(auth.family.id); if (!r.error) setRealBudgets(r.data||[]); } } setShowAdd(false); setAddCat(''); setAddLimit(''); setSaving(false); } return (
{/* Header */}

Budget

Mei 2026 · {budgets.length} kategori
{/* Summary card */}
Sisa budget bulan ini
{fmtRp(remaining)}
dari {fmtRp(totalLimit, { short: true })} budget total
= 90 ? theme.dangerSoft : pct >= 75 ? theme.warningSoft : theme.accent.tint, color: pct >= 90 ? theme.danger : pct >= 75 ? theme.warning : theme.accent[600], fontSize: 13, fontWeight: 700, fontVariantNumeric: 'tabular-nums', }}>{pct}%
{/* Segmented progress: each segment is a category */}
{budgets.map((b, i) => { const c = b.isReal ? {color: b.catColor, label: b.catName} : (CATEGORIES[b.cat]||{color:'#6366f1',label:b.cat}); const w = (b.limit / totalLimit) * 100; const usedPct = Math.min(1, b.used / b.limit); return (
); })}
{/* Per-category list */}
{budgets.length === 0 ? (
🎯
Belum ada budget
Tap + untuk atur budget per kategori
) : budgets.map((b) => { const c = b.isReal ? {color: b.catColor, label: b.catName} : (CATEGORIES[b.cat]||{color:'#6366f1',label:b.cat}); const pct = (b.used / b.limit) * 100; const isOver = pct > 100; const left = b.limit - b.used; return (
{c.label}
{isOver ? Over Rp {Math.abs(left).toLocaleString('id-ID')} : <>Sisa {fmtRp(left, { short: true })}}
{fmtRp(b.used, { short: true })}
/ {fmtRp(b.limit, { short: true })}
); }) } )}
{/* Add Budget Modal */} {showAdd && (
{ if(e.target===e.currentTarget) setShowAdd(false); }}>
Set Budget
Kategori
Batas per Bulan
Rp setAddLimit(parseInt(e.target.value.replace(/\D/g,'')||'0').toLocaleString('id-ID'))} style={{flex:1,height:48,border:0,outline:'none',background:'transparent',fontSize:16,fontWeight:700,color:theme.fg1,fontFamily:'inherit'}}/>
)}
); } function StatPair({ label, value, theme }) { return (
{label}
{value}
); } // ─── GOALS / TABUNGAN ──────────────────────────────────────────── function ScreenGoals({ theme, accent, scenario, auth }) { const [realGoals, setRealGoals] = React.useState(null); const familyId = auth && auth.family && auth.family.id; React.useEffect(function() { if (!familyId || !window.sbGetGoals) return; sbGetGoals(familyId).then(function(r){ if(!r.error) setRealGoals(r.data||[]); }); }, [familyId]); const isAuth = !!(auth && auth.family); const [showAddGoal, setShowAddGoal] = React.useState(false); const [goalName, setGoalName] = React.useState(''); const [goalEmoji, setGoalEmoji] = React.useState('🎯'); const [goalTarget, setGoalTarget] = React.useState(''); const [goalDate, setGoalDate] = React.useState(''); const [savingGoal, setSavingGoal] = React.useState(false); async function handleAddGoal() { if (!goalName || !goalTarget) return; setSavingGoal(true); var amount = parseInt(goalTarget.replace(/\D/g,''), 10); if (window.sbAddGoal && auth && auth.family && auth.user) { await sbAddGoal(auth.family.id, auth.user.id, { name: goalName, emoji: goalEmoji, target: amount, due: goalDate || null, }); var r = await sbGetGoals(auth.family.id); if (!r.error) setRealGoals(r.data||[]); } setShowAddGoal(false); setGoalName(''); setGoalTarget(''); setGoalDate(''); setSavingGoal(false); } // Normalize goal shape: Supabase vs dummy function normGoal(g) { if (g.target_amount !== undefined) { return { id: g.id, name: g.name, emoji: g.icon || '🎯', target: g.target_amount, saved: g.current_amount || 0, due: g.target_date || new Date(Date.now() + 365*24*3600*1000).toISOString(), contribs: [], isReal: true, }; } return g; } const goals = (isAuth ? (realGoals||[]) : scenario.goals).map(normGoal); const totalSaved = goals.reduce((a, g) => a + g.saved, 0); const totalTarget = goals.reduce((a, g) => a + g.target, 0); return (
{/* Header */}

Tabungan

{goals.length} tujuan keluarga
{/* Summary hero */}
Total terkumpul
{fmtRp(totalSaved)}
dari target {fmtRp(totalTarget, { short: true })}
0 ? (totalSaved / totalTarget) * 100 : 0}%`, height: '100%', background: '#fff', borderRadius: 4, }} />
{/* Goals list */}
{goals.length === 0 ? (
🐷
Belum ada goals tabungan
Tap + untuk tambah goals pertama
) : goals.map((g) => ( ))} ) }
{/* Add Goal Modal */} {showAddGoal && (
{ if(e.target===e.currentTarget) setShowAddGoal(false); }}>
Tambah Goal
{['🎯','🏠','🚗','✈️','🎓','💍','📱','💻','🏖️','💰','🐷','🎸'].map(function(e){ return ; })}
Nama Goal
setGoalName(e.target.value)} autoFocus style={{width:'100%',height:48,borderRadius:12,border:'1.5px solid '+theme.border,background:theme.bg,color:theme.fg1,fontSize:14,padding:'0 14px',fontFamily:'inherit',outline:'none'}}/>
Target Dana
Rp setGoalTarget(parseInt(e.target.value.replace(/\D/g,'')||'0').toLocaleString('id-ID'))} style={{flex:1,height:48,border:0,outline:'none',background:'transparent',fontSize:16,fontWeight:700,color:theme.fg1,fontFamily:'inherit'}}/>
Target Tanggal (opsional)
setGoalDate(e.target.value)} style={{width:'100%',height:48,borderRadius:12,border:'1.5px solid '+theme.border,background:theme.bg,color:theme.fg1,fontSize:14,padding:'0 14px',fontFamily:'inherit',outline:'none'}}/>
)}
); } function GoalCard({ goal, scenario, theme, accent }) { const pct = Math.min(100, (goal.saved / goal.target) * 100); const remaining = goal.target - goal.saved; const due = new Date(goal.due); const daysLeft = Math.max(0, Math.round((due - new Date()) / 86400000)); const monthsLeft = Math.round(daysLeft / 30); const monthly = monthsLeft > 0 ? remaining / monthsLeft : 0; const contribMembers = (goal.contribs||[]).map((c) => scenario.members.find((m) => m.id === c.m) ).filter(Boolean); return (
{goal.emoji}
{goal.name}
{monthsLeft > 0 ? `${monthsLeft} bulan lagi` : 'Sudah jatuh tempo'} · {fmtDate(goal.due)} {due.getFullYear()}
{/* Amount + progress */}
{fmtRp(goal.saved, { short: true })} / {fmtRp(goal.target, { short: true })}
= 90 ? 'success' : 'accent'}> {Math.round(pct)}%
{/* Footer: contributors + monthly need */}
{contribMembers.length} kontributor
{monthsLeft > 0 && (
{fmtRp(monthly, { short: true })}/bln
)}
); } Object.assign(window, { ScreenBudget, ScreenGoals });