// nav.jsx — bottom navigation + transaction form sheet // ─── Bottom Nav ─────────────────────────────────────────────────── function BottomNav({ active, onChange, onFab, theme, accent }) { const tabs = [ { id: 'home', label: 'Beranda', icon: 'home' }, { id: 'tx', label: 'Transaksi', icon: 'arrow-left-right' }, { id: 'fab' }, { id: 'report', label: 'Laporan', icon: 'pie-chart' }, { id: 'more', label: 'Lainnya', icon: 'layout-grid' }, ]; return (
{tabs.map((t) => { if (t.id === 'fab') return ( ); const on = active === t.id; return ( ); })}
); } // ─── Transaction Form ───────────────────────────────────────────── function TxForm({ open, onClose, onSave, theme, accent, scenario, defaultBy, auth }) { const T = theme; const [kind, setKind] = React.useState('expense'); const [amount, setAmount] = React.useState(0); const [cat, setCat] = React.useState(null); const [note, setNote] = React.useState(''); const [date, setDate] = React.useState('Hari ini'); const [accountId, setAccountId] = React.useState(''); const [accounts, setAccounts] = React.useState([]); const [scanning, setScanning] = React.useState(false); const [scanMsg, setScanMsg] = React.useState(''); const fileRef = React.useRef(null); React.useEffect(() => { if (!open) return; setKind('expense'); setAmount(0); setCat(null); setNote(''); setDate('Hari ini'); setAccountId(''); setScanMsg(''); setScanning(false); if (auth && auth.family && window.SB) { window.SB.from('accounts').select('id,name,type,balance') .eq('family_id', auth.family.id).neq('type','credit').order('name') .then(function(r){ if (!r.error) setAccounts(r.data || []); }); } }, [open]); async function handleScan(file) { setScanning(true); setScanMsg('Membaca struk…'); try { const b64 = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result.split(',')[1]); r.onerror = rej; r.readAsDataURL(file); }); const resp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 400, messages: [{ role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: file.type || 'image/jpeg', data: b64 } }, { type: 'text', text: 'Struk/nota/tagihan ini. Balas JSON saja tanpa backtick: {"total":,"merchant":"","category":"","note":""}' } ] }] }) }); const data = await resp.json(); const text = (data.content||[]).filter(b=>b.type==='text').map(b=>b.text).join(''); const parsed = JSON.parse(text.replace(/```json|```/g,'').trim()); if (parsed.total > 0) setAmount(parsed.total); if (parsed.merchant || parsed.note) setNote([parsed.merchant, parsed.note].filter(Boolean).join(' — ')); const catMap = { makan:'food_ex', belanja:'shop_ex', transport:'transport_ex', tagihan:'bills_ex', hiburan:'fun_ex', kesehatan:'health_ex', pendidikan:'edu_ex', rumah:'home_ex', anak:'kids_ex', hadiah:'gift_ex', donasi:'donate_ex', lainnya:'other_ex', }; const ck = catMap[parsed.category]; if (ck && CATEGORIES[ck]) setCat(CATEGORIES[ck]); setScanMsg('✓ ' + (parsed.merchant||'Struk') + ' · Rp ' + (parsed.total||0).toLocaleString('id-ID')); } catch(e) { setScanMsg('Gagal baca struk'); } setScanning(false); } const cats = Object.values(CATEGORIES).filter((c) => c.kind === kind); const canSave = amount > 0 && cat; const accentColor = kind==='expense' ? (T.danger||'#ef4444') : accent[500]; return ( {/* Header */}

Catat Transaksi

{/* Income / Expense toggle */}
{['income','expense'].map((k) => ( ))}
{/* Scrollable body */}
{/* Amount */}
Jumlah
Rp 0 ? amount.toLocaleString('id-ID') : ''} onChange={function(e){ var raw = e.target.value.replace(/\D/g,''); setAmount(raw ? Math.min(parseInt(raw,10), 999999999999) : 0); }} style={{ flex:1, border:0, outline:0, background:'transparent', fontFamily:'inherit', fontSize:32, fontWeight:800, color: amount>0 ? T.fg1 : T.fg4, letterSpacing:'-0.02em', fontVariantNumeric:'tabular-nums', minWidth:0, }} />
{/* Scan struk button — terpisah dari amount field */}
{/* Category */}
Kategori {!cat && *}
{cats.map((c) => { const on = cat && cat.id === c.id; return ( ); })}
{/* Rekening + Tanggal */}
Rekening
Tanggal
{/* Catatan */}
Catatan
setNote(e.target.value)} placeholder="Opsional — nama toko, keterangan, dll" style={{ width:'100%', height:44, borderRadius:12, border:`1.5px solid ${T.border}`, outline:'none', background:T.surface, color:T.fg1, fontSize:14, padding:'0 14px', fontFamily:'inherit', boxSizing:'border-box', }} />
{/* Save button */}
); } function selectStyle(T) { return { appearance:'none', WebkitAppearance:'none', border:`1.5px solid ${T.border}`, background:T.surface, color:T.fg1, height:44, padding:'0 12px', borderRadius:12, fontSize:14, fontWeight:500, width:'100%', fontFamily:'inherit', outline:'none', }; } // ─── Transfer Form ──────────────────────────────────────────────── function TransferForm({ open, onClose, onDone, theme, accent, auth }) { const T = theme; const [fromId, setFromId] = React.useState(''); const [toId, setToId] = React.useState(''); const [amount, setAmount] = React.useState(0); const [note, setNote] = React.useState(''); const [date, setDate] = React.useState('Hari ini'); const [accounts, setAccounts] = React.useState([]); const [saving, setSaving] = React.useState(false); const [msg, setMsg] = React.useState(''); React.useEffect(() => { if (!open) return; setFromId(''); setToId(''); setAmount(0); setNote(''); setDate('Hari ini'); setMsg(''); setSaving(false); if (auth && auth.family && window.SB) { window.SB.from('accounts').select('id,name,type,balance') .eq('family_id', auth.family.id).eq('is_active', true).order('name') .then(function(r){ if(!r.error) setAccounts(r.data||[]); }); } }, [open]); const fromAcc = accounts.find(a => a.id === fromId); const toAcc = accounts.find(a => a.id === toId); const toInvest = toAcc && toAcc.type === 'investment'; const canSave = fromId && toId && fromId !== toId && amount > 0; async function handleSave() { if (!canSave || saving) return; setSaving(true); setMsg(''); // Resolve date var today = new Date(); var resolvedDate = (!date || date === 'Hari ini') ? today.toISOString().split('T')[0] : date === 'Kemarin' ? new Date(today.setDate(today.getDate()-1)).toISOString().split('T')[0] : date; var res = await window.sbTransfer(auth.family.id, auth.user.id, { fromId, toId, amount, toType: toAcc.type, note: note || ('Transfer ke ' + (toAcc.name||'rekening')), date: resolvedDate, }); if (res.error) { setMsg('Error: ' + (res.error.message || 'Gagal')); setSaving(false); return; } window.dispatchEvent(new Event('tx-added')); onDone && onDone(); onClose(); } const fmtBal = (n) => n >= 1000000 ? 'Rp ' + (n/1000000).toFixed(1).replace('.0','') + 'jt' : n >= 1000 ? 'Rp ' + Math.round(n/1000) + 'rb' : 'Rp ' + n; return ( {/* Header */}

Transfer

Pindah saldo antar rekening
{/* Amount */}
Jumlah
Rp 0 ? amount.toLocaleString('id-ID') : ''} onChange={function(e){ var raw = e.target.value.replace(/\D/g,''); setAmount(raw ? Math.min(parseInt(raw,10), 999999999999) : 0); }} style={{ flex:1, border:0, outline:0, background:'transparent', fontFamily:'inherit', fontSize:32, fontWeight:800, color: amount>0 ? T.fg1 : T.fg4, letterSpacing:'-0.02em', }} />
{/* From → To */}
Dari
{fromAcc &&
Saldo: {fmtBal(fromAcc.balance)}
}
Ke
{toAcc &&
Saldo: {fmtBal(toAcc.balance)}
}
{/* Info banner kalau ke investasi */} {toInvest && (
📈
Transfer ke investasi — saldo {toAcc.name} dan cost basis akan bertambah Rp {amount>0?amount.toLocaleString('id-ID'):0}. Dicatat sebagai investasi, bukan pengeluaran.
)} {/* Warning kalau saldo tidak cukup */} {fromAcc && amount > 0 && amount > fromAcc.balance && (
⚠️ Saldo {fromAcc.name} tidak cukup ({fmtBal(fromAcc.balance)})
)} {/* Tanggal + Catatan */}
Tanggal
Catatan
setNote(e.target.value)} placeholder="Opsional" style={{ width:'100%', height:44, borderRadius:12, boxSizing:'border-box', border:`1.5px solid ${T.border}`, outline:'none', background:T.surface, color:T.fg1, fontSize:14, padding:'0 14px', fontFamily:'inherit', }} />
{msg &&
{msg}
}
{/* Save */}
); } // ─── FAB Picker — pilih tipe transaksi ─────────────────────────── function FabPicker({ open, onClose, onSelect, theme, accent }) { const T = theme; const options = [ { id:'income', label:'Pemasukan', icon:'arrow-down-left', color:accent[500], desc:'Gaji, bonus, dll' }, { id:'expense', label:'Pengeluaran', icon:'arrow-up-right', color:T.danger||'#ef4444', desc:'Belanja, makan, dll' }, { id:'transfer', label:'Transfer', icon:'arrow-left-right', color:'#8b5cf6', desc:'Pindah / investasi' }, ]; return (
Catat apa?
{options.map(opt => ( ))}
); } Object.assign(window, { BottomNav, TxForm, TransferForm, FabPicker });