// 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
setShowAdd(true)}>
{/* 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
setAddCat(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'}}>
-- Pilih kategori --
{Object.entries(CATEGORIES).filter(function([k,v]){return v.kind==='expense';}).map(function([k,v]){
return {v.label} ;
})}
{saving ? 'Menyimpan...' : '✓ Simpan Budget'}
)}
);
}
function StatPair({ label, value, theme }) {
return (
);
}
// ─── 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
setShowAddGoal(true)}>
{/* 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 setGoalEmoji(e)}
style={{width:40,height:40,borderRadius:10,fontSize:20,border:'2px solid '+(goalEmoji===e?accent[500]:theme.border),background:goalEmoji===e?accent.tint:'transparent',cursor:'pointer'}}>{e} ;
})}
{savingGoal ? 'Menyimpan...' : goalEmoji+' Buat Goal'}
)}
);
}
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 });