// screen-receipt.jsx — Foto Struk → AI → Auto-fill Transaksi // Uses Claude claude-sonnet-4-20250514 Vision via Anthropic API proxy const ANTHROPIC_KEY = window.INDIECASH_CONFIG?.anthropicKey || ''; function ScreenReceipt({ theme, accent, scenario, onOpenSheet, onTransactionAdded }) { const T = theme; const [step, setStep] = React.useState('idle'); // idle | processing | result | error const [previewUrl, setPreviewUrl] = React.useState(null); const [result, setResult] = React.useState(null); const [errorMsg, setErrorMsg] = React.useState(''); const [history, setHistory] = React.useState([]); const fileRef = React.useRef(null); const cameraRef = React.useRef(null); const CATEGORIES_LIST = Object.values(CATEGORIES).filter(c => c.kind === 'expense'); async function processImage(file) { if (!file) return; // Preview const url = URL.createObjectURL(file); setPreviewUrl(url); setStep('processing'); setResult(null); try { // Convert to base64 const base64 = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = () => res(reader.result.split(',')[1]); reader.onerror = rej; reader.readAsDataURL(file); }); const mediaType = file.type || 'image/jpeg'; const prompt = `Kamu adalah asisten pencatat keuangan Indonesia. Analisis struk/nota/bukti pembayaran ini. Ekstrak informasi berikut dan kembalikan HANYA JSON valid (tanpa markdown, tanpa penjelasan): { "merchant": "nama toko/merchant", "total": 150000, "tanggal": "2024-05-26", "items": [ {"nama": "nama item", "harga": 50000, "qty": 1} ], "kategori": "makan|belanja|transport|tagihan|hiburan|kesehatan|pendidikan|rumah|lainnya_ex", "metode_bayar": "cash|debit|kredit|qris|transfer", "catatan": "ringkasan singkat 1 kalimat dalam bahasa Indonesia", "confidence": 0.95 } Aturan: - total harus angka integer (dalam rupiah, tanpa titik/koma) - tanggal format YYYY-MM-DD, jika tidak ada pakai hari ini - kategori pilih yang paling sesuai dari daftar yang ada - confidence antara 0-1 (seberapa yakin kamu dengan hasilnya) - Jika struk tidak terbaca/bukan struk, kembalikan {"error": "Bukan struk yang valid"}`; // Call backend proxy — key stays on server, never exposed to browser const response = await fetch('/api/receipt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: base64, media_type: mediaType }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || `Server error ${response.status}`); } const proxyResult = await response.json(); if (!proxyResult.ok) throw new Error(proxyResult.error || 'Gagal memproses'); // proxyResult.data is already parsed JSON from server const parsed = proxyResult.data; if (parsed.error) throw new Error(parsed.error); setResult(parsed); setStep('result'); return; // skip the rest of the try block } catch (err) { setErrorMsg(err.message || 'Gagal memproses gambar'); setStep('error'); } } function handleFile(e) { const file = e.target.files?.[0]; if (file) processImage(file); } function confirmAndSave() { if (!result) return; // Add to history const entry = { id: Date.now(), merchant: result.merchant, total: result.total, kategori: result.kategori, tanggal: result.tanggal, catatan: result.catatan, previewUrl, savedAt: new Date().toISOString(), }; setHistory(h => [entry, ...h].slice(0, 10)); // Trigger add transaction sheet with pre-filled data if (onTransactionAdded) onTransactionAdded(entry); setStep('saved'); setTimeout(() => { setStep('idle'); setPreviewUrl(null); setResult(null); }, 2000); } function retry() { setStep('idle'); setPreviewUrl(null); setResult(null); setErrorMsg(''); } const catInfo = result ? (CATEGORIES[result.kategori] || CATEGORIES.lainnya_ex) : null; return (
{/* Header */}
Scan Struk
Foto struk → AI proses → Auto catat
{/* ── IDLE: Upload area ── */} {(step === 'idle' || step === 'saved') && ( <> {step === 'saved' && (
Transaksi berhasil disimpan!
)} {/* Camera / Upload buttons */}
{/* Tips */}
💡 Tips foto yang bagus
{[ 'Pastikan struk tidak terlipat atau kusut', 'Foto dalam cahaya yang cukup', 'Seluruh teks struk terlihat jelas', 'Bisa juga foto QRIS, tagihan, atau nota tangan', ].map((tip, i) => (
{tip}
))}
{/* History */} {history.length > 0 && (
Riwayat Scan
{history.map(h => (
{h.previewUrl && ( )}
{h.merchant || 'Merchant'}
{h.catatan}
{fmtRp(h.total)}
))}
)} )} {/* ── PROCESSING ── */} {step === 'processing' && (
{previewUrl && (
struk
AI sedang membaca struk...
)}
Menganalisis dengan Claude AI · Harap tunggu
)} {/* ── ERROR ── */} {step === 'error' && (
{previewUrl && ( )}
Gagal membaca struk
{errorMsg}
)} {/* ── RESULT ── */} {step === 'result' && result && (
{/* Preview + confidence */}
= 0.85 ? accent[500] : '#f59e0b', color: '#fff', borderRadius: 99, padding: '3px 10px', fontSize: 11, fontWeight: 700, }}> {Math.round((result.confidence || 0) * 100)}% akurat
{/* Parsed result card */}
{result.merchant || '—'}
{catInfo?.label} · {result.metode_bayar?.toUpperCase() || 'CASH'}
{fmtRp(result.total)}
{result.tanggal}
{/* Items */} {result.items?.length > 0 && (
Detail item
{result.items.map((item, i) => (
{item.qty > 1 ? `${item.qty}× ` : ''}{item.nama} {fmtRp(item.harga * (item.qty || 1))}
))}
)} {result.catatan && (
"{result.catatan}"
)}
{/* Action buttons */}
)}
); } // Small helper (re-use if already defined) if (typeof fmtRp === 'undefined') { window.fmtRp = (v) => { if (v >= 1e9) return `Rp ${(v/1e9).toFixed(1)}M`; if (v >= 1e6) return `Rp ${(v/1e6).toFixed(1)}jt`; if (v >= 1e3) return `Rp ${(v/1e3).toFixed(0)}rb`; return `Rp ${Math.round(v)}`; }; } Object.assign(window, { ScreenReceipt });