// 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 (
onChange(t.id)} style={{
appearance: 'none', background: 'transparent', border: 0,
cursor: 'pointer', flex: 1, padding: '8px 0',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
}}>
{t.label}
);
})}
);
}
// ─── 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) => (
{ setKind(k); setCat(null); }} style={{
position:'relative', flex:1, appearance:'none', border:0, background:'transparent',
padding:'11px 0', cursor:'pointer', zIndex:1,
fontSize:14, fontWeight:700, color: kind===k ? '#fff' : T.fg3,
display:'flex', alignItems:'center', justifyContent:'center', gap:6,
transition:'color 200ms',
}}>
{k==='income' ? 'Pemasukan' : 'Pengeluaran'}
))}
{/* 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 */}
fileRef.current && fileRef.current.click()}
disabled={scanning}
style={{
appearance:'none', border:`1.5px dashed ${accent[300]||accent[500]}`,
cursor: scanning ? 'wait' : 'pointer', width:'100%',
background: scanning ? T.surfaceAlt : accent.tint||accent[50]||'rgba(16,185,129,0.06)',
borderRadius:14, padding:'12px 16px',
display:'flex', alignItems:'center', gap:12,
}}>
{scanning
? ⏳
:
}
{scanning ? 'Membaca struk…' : 'Scan / Upload Struk'}
{scanMsg || 'Foto struk atau nota → AI otomatis isi jumlah & kategori'}
{/* Category */}
Kategori {!cat && * }
{cats.map((c) => {
const on = cat && cat.id === c.id;
return (
setCat(on ? null : c)} style={{
appearance:'none',
border: on ? `2px solid ${c.color}` : '2px solid transparent',
cursor:'pointer',
background: on ? c.color+'18' : T.surfaceAlt,
padding:'10px 4px 8px', borderRadius:14,
display:'flex', flexDirection:'column', alignItems:'center', gap:5,
transition:'all 150ms',
boxShadow: on ? `0 2px 12px ${c.color}33` : 'none',
}}>
{c.label}
);
})}
{/* Rekening + Tanggal */}
Rekening
setAccountId(e.target.value)} style={selectStyle(T)}>
— Pilih —
{accounts.map((a) => {a.name} )}
Tanggal
setDate(e.target.value)} style={selectStyle(T)}>
Hari ini
Kemarin
{/* 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 */}
{ onSave?.({ kind, amount, cat, note, date, account_id: accountId||null }); onClose(); }}
style={{
appearance:'none', border:0, width:'100%', height:52, borderRadius:14,
cursor: canSave ? 'pointer' : 'not-allowed',
background: canSave
? `linear-gradient(135deg, ${accent.grad[0]}, ${accent.grad[1]})`
: T.surfaceAlt,
color: canSave ? '#fff' : T.fg4,
fontSize:16, fontWeight:700, fontFamily:'inherit',
boxShadow: canSave ? `0 6px 20px ${accent[500]}44` : 'none',
transition:'all 200ms',
}}>
{canSave ? 'Simpan Transaksi' : 'Pilih kategori dulu'}
);
}
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
setFromId(e.target.value)} style={selectStyle(T)}>
— Pilih —
{accounts.map(a => {a.name} )}
{fromAcc &&
Saldo: {fmtBal(fromAcc.balance)}
}
Ke
setToId(e.target.value)} style={selectStyle(T)}>
— Pilih —
{accounts.filter(a=>a.id!==fromId).map(a => (
{a.name}{a.type==='investment' ? ' 📈' : ''}
))}
{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
setDate(e.target.value)} style={selectStyle(T)}>
Hari ini
Kemarin
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 */}
{saving ? 'Memproses…' : canSave ? (toInvest ? '📈 Investasikan' : '↔ Transfer') : 'Lengkapi data dulu'}
);
}
// ─── 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 => (
{ onSelect(opt.id); }}
style={{
appearance:'none', border:`1.5px solid ${opt.color}22`,
cursor:'pointer', background: opt.color+'0d',
borderRadius:16, padding:'14px 16px',
display:'flex', alignItems:'center', gap:14, textAlign:'left',
}}>
))}
);
}
Object.assign(window, { BottomNav, TxForm, TransferForm, FabPicker });