// screen-dashboard.jsx — Beranda / Home (priority screen) function ScreenDashboard({ theme, accent, scenario, hideBalance, onToggleHide, onFab, onNavigate, onOpenSheet, auth }) { const [realTx, setRealTx] = React.useState(null); const [realBudgets, setRealBudgets] = React.useState(null); const familyId = auth && auth.family && auth.family.id; React.useEffect(function() { if (!familyId) return; function reload() { if (window.sbGetTransactions) sbGetTransactions(familyId).then(function(r) { if (!r.error) setRealTx(r.data || []); }); if (window.sbGetBudgets) sbGetBudgets(familyId).then(function(r) { if (!r.error) setRealBudgets(r.data || []); }); } reload(); window.addEventListener('tx-added', reload); return function() { window.removeEventListener('tx-added', reload); }; }, [familyId]); const s = scenario; const isAuth = !!(auth && auth.family); // Ketika authenticated, JANGAN pakai dummy — pakai real data (bisa [] kalau kosong) const transactions = isAuth ? (realTx || []) : s.transactions; const budgets = isAuth ? (realBudgets || []) : s.budgets; const incomeTotal = transactions.filter(function(t){ return (t.type||t.kind)==='income'; }).reduce(function(a,t){ return a+t.amount; }, 0); const expenseTotal = transactions.filter(function(t){ return (t.type||t.kind)==='expense'; }).reduce(function(a,t){ return a+t.amount; }, 0); const monthDelta = isAuth ? incomeTotal - expenseTotal : s.monthlyIncome - s.monthlyExpense; const overBudgets = isAuth ? budgets.filter(function(b){ return (b.used_pct||0) >= 90; }) : s.budgets.filter(function(b){ return b.used/b.limit >= 0.9; }); // Patch tx dari Supabase ke shape yang dipahami TxRow function patchTx(tx) { if (tx.kind) return tx; // Map category name to CATEGORIES key var catName = tx.category ? (tx.category.name||'').toLowerCase().replace(/\s+/g,'_') : 'lainnya_ex'; // Try to find matching key in CATEGORIES var catKey = catName; if (!CATEGORIES[catKey]) { // Try with _ex or _in suffix catKey = catName + '_ex'; if (!CATEGORIES[catKey]) catKey = catName + '_in'; if (!CATEGORIES[catKey]) catKey = (tx.type==='income' ? 'lainnya_in' : 'lainnya_ex'); } return Object.assign({}, tx, { kind: tx.type, cat: catKey, by: tx.user_id, profileObj: tx.profile, }); } const recent = transactions.slice(0, 5).map(patchTx); const patchedRecent = recent; // Build displayScenario — real data jika authenticated, dummy jika demo var memberShort = isAuth && auth.profile ? ((auth.profile.full_name || '').split(' ')[0] || 'Kamu') : s.members[0].short; var displayScenario = isAuth ? Object.assign({}, s, { family: { name: auth.family.name, code: auth.family.invite_code }, members: [{ id: auth.profile && auth.profile.id, name: auth.profile && auth.profile.full_name || 'Kamu', short: memberShort, role: 'admin', color: '#10b981' }], balance: monthDelta, monthlyIncome: incomeTotal, monthlyExpense: expenseTotal, transactions: transactions.map(patchTx), budgets: budgets, activity: [], week: [0,0,0,0,0,0,0], weekLabels: ['Sen','Sel','Rab','Kam','Jum','Sab','Min'], }) : s; return (
{overBudgets.length > 0 && ( onNavigate('budget')} /> )} onNavigate('tx')} />
); } // ─── Top app bar ──────────────────────────────────────────────── function DashHeader({ theme, accent, scenario }) { const me = scenario.members[0]; // assume "me" is first member return (
Halo, {me?.short || me?.name?.split(' ')[0] || 'Bram'} 👋
{scenario.family.name}
); } function iconBtn(theme) { return { appearance: 'none', border: 0, cursor: 'pointer', width: 40, height: 40, borderRadius: 12, background: theme.surface, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: theme.shadow1, border: `0.5px solid ${theme.border}`, }; } // ─── Balance card (gradient hero) ─────────────────────────────── function BalanceCard({ theme, accent, scenario, hide, onToggleHide, auth }) { const s = scenario; const monthDelta = s.monthlyIncome - s.monthlyExpense; const [breakdown, setBreakdown] = React.useState(null); const familyId = auth && auth.family && auth.family.id; React.useEffect(function() { if (!familyId || !window.SB) return; function loadBreakdown() { Promise.all([ window.SB.from('accounts').select('balance,type,cost_basis,is_active,credit_limit').eq('family_id', familyId).eq('is_active', true), window.SB.from('savings_goals').select('current_amount,account_id').eq('family_id', familyId), ]).then(function(res) { var accs = res[0].data || []; var goals = res[1].data || []; // totalPocket = sum current_amount semua goals aktif var totalPocket = goals.reduce(function(s,g){return s+(g.current_amount||0);}, 0); var investasi = accs.filter(function(a){return a.type==='investment';}).reduce(function(s,a){return s+a.balance;}, 0); var investasiCost = accs.filter(function(a){return a.type==='investment'&&a.cost_basis>0;}).reduce(function(s,a){return s+a.cost_basis;}, 0); var hutangKK = accs.filter(function(a){return a.type==='credit';}).reduce(function(s,a){return s+a.balance;}, 0); // kasTotal = hanya rekening bank/cash/ewallet (bukan investasi, bukan kredit) var kasTotal = accs.filter(function(a){return a.type==='bank'||a.type==='cash'||a.type==='ewallet';}).reduce(function(s,a){return s+a.balance;}, 0); // kasTotal = saldo fisik total (sudah include pocket di dalamnya) // tersedia = bebas dipakai = kasTotal - pocket var aktif = kasTotal; var tersedia = kasTotal - totalPocket; // netWorth = kas + investasi - hutang KK var netWorth = kasTotal + investasi - hutangKK; setBreakdown({ aktif, tersedia, pocket: totalPocket, investasi, investasiPnl: investasi - investasiCost, hutangKK, kasTotal, netWorth, }); }); } loadBreakdown(); window.addEventListener('tx-added', loadBreakdown); return function() { window.removeEventListener('tx-added', loadBreakdown); }; }, [familyId]); return (
{/* decorative orbs */}
Saldo Keluarga
{/* Label saldo */}
{breakdown ? 'Net Worth' : 'Saldo bulan ini'}
{fmtRp(breakdown ? breakdown.netWorth : s.balance, { hide })}
{/* Breakdown aktif / pocket / investasi */} {breakdown ? (
{[ { label:'Bebas', val: breakdown.tersedia, icon:'✓', note: 'diluar pocket' }, { label:'Pocket', val: breakdown.pocket, icon:'🔒', note: 'ada di rekening' }, { label:'Investasi', val: breakdown.investasi, icon:'📈', note: breakdown.investasiPnl !== 0 ? (breakdown.investasiPnl>0?'+':'')+fmtRp(breakdown.investasiPnl,{short:true})+' P&L' : 'portofolio' }, ].map(function(item) { return (
{item.icon} {item.label}
{hide ? '••••' : fmtRp(item.val,{short:true})}
{item.note &&
{item.note}
}
); })}
{breakdown.hutangKK > 0 && (
💳 Tagihan KK: -{hide ? '••••' : fmtRp(breakdown.hutangKK,{short:true})}
)}
) : null}
); } function DeltaCol({ label, amount, hide, positive }) { const isPos = positive || amount > 0; return (
{label}
{fmtRp(amount, { short: true, sign: !positive && amount !== 0, hide })}
); } // ─── Quick actions ────────────────────────────────────────────── function QuickActions({ theme, accent, onOpenSheet }) { return (
onOpenSheet('income')} /> onOpenSheet('expense')} />
); } // ─── 7-day chart card ─────────────────────────────────────────── function WeekChartCard({ theme, accent, scenario }) { const total = scenario.week.reduce((a, b) => a + b, 0); const avg = total / scenario.week.length; return (
Pengeluaran 7 hari
{fmtRp(total)}
12% lebih hemat
Rata-rata · {fmtRp(avg, { short: true })}/hari Tertinggi · {fmtRp(Math.max(...scenario.week), { short: true })}
); } // ─── Budget alerts (over-budget chips) ────────────────────────── function BudgetAlerts({ theme, accent, budgets, onSeeAll }) { return (
{budgets.map((b) => { const c = CATEGORIES[b.cat]; const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); const isOver = b.used > b.limit; const color = pct >= 100 ? theme.danger : pct >= 90 ? theme.warning : theme.accent[500]; return (
{c.label}
{isOver && Over}
{pct}% {fmtRp(b.used, { short: true })} / {fmtRp(b.limit, { short: true })}
); })}
); } // ─── Recent transactions ──────────────────────────────────────── function RecentTransactions({ theme, accent, scenario, items, onSeeAll }) { return (
{items.map((tx, i) => ( ))}
); } // Single transaction row — reused across Dashboard & Tx list function TxRow({ tx, scenario, theme, accent, divider }) { const cat = CATEGORIES[tx.cat] || { label: tx.cat || 'Lainnya', color: '#94a3b8', icon: '📋', kind: tx.kind||'expense' }; const m = getMember(scenario, tx.by) || { short: tx.profileObj?.full_name?.split(' ')[0] || tx.profile?.full_name?.split(' ')[0] || 'Kamu', name: tx.profileObj?.full_name || tx.profile?.full_name || 'Anggota', color: '#10b981', }; const isIncome = tx.kind === 'income'; return (
{tx.note || cat.label}
{m.short} · {fmtRelDay(tx.date)} · {cat.label}
{fmtRp(tx.amount, { sign: true, short: tx.amount >= 1_000_000 }) .replace('+', isIncome ? '+' : '') .replace('−', '−')} {!isIncome && !String(tx.amount).startsWith('−') && tx.kind === 'expense' && /* fmtRp already handles negative; we keep formatting consistent */ ''}
); } // ─── Activity feed (WhatsApp-style) ───────────────────────────── function ActivityFeed({ theme, accent, scenario }) { return (
{scenario.activity.map((a, i) => { const m = getMember(scenario, a.m) || { short: 'Kamu', name: 'Anggota', color: '#10b981', }; const isLast = i === scenario.activity.length - 1; return (
{m.short} {' '}{a.verb}{' '} {a.what}
{fmtRelDay(a.ts)} · {fmtTime(a.ts)}
); })}
); } function FireWidget({ theme, accent, auth, monthlyExpense, onOpenFire }) { var T = theme; var _fw1 = React.useState(0), netWorth = _fw1[0], setNetWorth = _fw1[1]; var familyId = auth && auth.family && auth.family.id; React.useEffect(function() { if (!familyId || !window.SB) return; window.SB.from('accounts').select('balance,type').eq('family_id', familyId).eq('is_active', true) .then(function(r) { if (!r.error && r.data) { var nw = r.data.filter(function(a){return a.type!=='credit';}).reduce(function(s,a){return s+a.balance;},0); setNetWorth(nw); } }); }, [familyId]); var monthlyExp = monthlyExpense || 0; var annualExp = monthlyExp * 12; var fireNum = annualExp > 0 ? annualExp / 0.04 : 0; var pct = fireNum > 0 ? Math.min(100, Math.round((netWorth / fireNum) * 100)) : 0; // Monthly savings needed (30yr horizon, 10% return) var r = 0.10 / 12, n = 30 * 12; var fvExisting = netWorth * Math.pow(1+r, n); var gap = Math.max(0, fireNum - fvExisting); var needPerMonth = gap > 0 ? Math.round(gap * r / (Math.pow(1+r, n) - 1)) : 0; if (!auth || !auth.family) return null; if (fireNum === 0 && netWorth === 0) return null; return (
); } Object.assign(window, { ScreenDashboard, TxRow, FireWidget, });