// ─── GUSTAI LAB — Admin: Receipt Management ─────────────────────────────────── // Accessible only to the verified admin phone. Loads all payment_receipts, // shows Gemini fraud-check results, and lets admin confirm or reject each one. const ADMIN_PHONE = '+66954766235'; // ← owner's verified phone (E.164) // ── Helpers ─────────────────────────────────────────────────────────────────── function timeAgo(isoStr) { if (!isoStr) return ''; const secs = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000); if (secs < 60) return `${secs}s ago`; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; return `${Math.floor(secs / 86400)}d ago`; } function fmtTime(isoStr) { if (!isoStr) return '—'; const d = new Date(isoStr); return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); } function fmtDate(isoStr) { if (!isoStr) return '—'; return new Date(isoStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } const STATUS_META = { pending: { label: 'Pending', bg: '#FFF3CD', color: '#856404', dot: '#FFC107' }, flagged: { label: 'Flagged ⚠️', bg: '#FDE8D0', color: '#7A3B00', dot: '#FF7A00' }, auto_confirmed: { label: 'Auto-confirmed', bg: '#D4EDDA', color: '#155724', dot: '#28A745' }, confirmed: { label: 'Confirmed ✓', bg: '#D4EDDA', color: '#155724', dot: '#28A745' }, rejected: { label: 'Rejected ✗', bg: '#F8D7DA', color: '#721C24', dot: '#DC3545' }, }; function StatusBadge({ status, small }) { const m = STATUS_META[status] || { label: status, bg: GL.offwhite, color: GL.muted, dot: GL.muted }; return ( {m.label} ); } // ── Receipt list item ───────────────────────────────────────────────────────── function ReceiptRow({ r, onSelect }) { const g = r.gemini_result || {}; return ( ); } // ── Receipt detail view ─────────────────────────────────────────────────────── function GLAdminReceiptDetail({ receipt: initialReceipt, onBack, onRefresh }) { const [receipt, setReceipt] = React.useState(initialReceipt); const [imageUrl, setImageUrl] = React.useState(null); const [note, setNote] = React.useState(receipt.admin_note || ''); const [processing, setProcessing] = React.useState(false); const [msg, setMsg] = React.useState(''); // Get signed URL for the receipt image (private bucket) React.useEffect(() => { if (!receipt.receipt_path || receipt.receipt_mime === 'application/pdf') return; window.db.storage.from('receipts') .createSignedUrl(receipt.receipt_path, 600) // 10-min signed URL .then(({ data }) => { if (data?.signedUrl) setImageUrl(data.signedUrl); }) .catch(e => console.error('[admin] signedUrl error:', e)); }, [receipt.receipt_path]); // Poll for Gemini result while status is still 'pending' React.useEffect(() => { if (receipt.status !== 'pending') return; const iv = setInterval(async () => { const { data } = await window.db.from('payment_receipts').select('*').eq('id', receipt.id).single(); if (data && data.status !== 'pending') { setReceipt(data); clearInterval(iv); } }, 4000); return () => clearInterval(iv); }, [receipt.status]); const action = async (newStatus) => { setProcessing(true); setMsg(''); try { const updates = { status: newStatus, admin_note: note || null, confirmed_at: (newStatus === 'confirmed') ? new Date().toISOString() : null, }; const { error } = await window.db.from('payment_receipts').update(updates).eq('id', receipt.id); if (error) throw error; setReceipt(r => ({ ...r, ...updates })); setMsg(newStatus === 'confirmed' ? '✓ Payment confirmed — customer notified.' : '✗ Receipt rejected.'); onRefresh(); } catch (e) { setMsg('Error: ' + (e?.message || 'Unknown')); } finally { setProcessing(false); } }; const g = receipt.gemini_result || {}; const isSettled = ['confirmed', 'auto_confirmed', 'rejected'].includes(receipt.status); return (
RECEIPT DETAIL
} />
{/* Status + order header */}
Order
{receipt.order_no}
{fmtDate(receipt.created_at)} · {fmtTime(receipt.created_at)}
฿{parseFloat(receipt.amount).toLocaleString()}
{/* Receipt image */}
Receipt Image
{receipt.receipt_mime === 'application/pdf' ? (
📄 PDF receipt — open in browser to view {receipt.receipt_path && (
)}
) : imageUrl ? (
Payment receipt
) : (
{receipt.receipt_path ? 'Loading image…' : 'No image uploaded'}
)}
{/* Gemini analysis */} {receipt.status === 'pending' ? (
Gemini is analysing the receipt…
{[0,1,2].map(i => (
))}
) : receipt.gemini_result ? (
Gemini Extracted
{receipt.gemini_confidence != null && (
= 0.9 ? '#D4EDDA' : receipt.gemini_confidence >= 0.7 ? '#FFF3CD' : '#F8D7DA', color: receipt.gemini_confidence >= 0.9 ? '#155724' : receipt.gemini_confidence >= 0.7 ? '#856404' : '#721C24', }}> Confidence: {(receipt.gemini_confidence * 100).toFixed(0)}%
)}
{[ { label: 'Amount', val: g.amount != null ? `฿${Number(g.amount).toFixed(2)}` : null, check: g.amount != null && Math.abs(g.amount - parseFloat(receipt.amount)) <= 1 }, { label: 'Transfer time', val: g.transfer_time ? fmtTime(g.transfer_time) + ' · ' + fmtDate(g.transfer_time) : null, check: !!g.transfer_time }, { label: 'Recipient', val: g.recipient_name || null, check: typeof g.recipient_name === 'string' && (g.recipient_name.toUpperCase().includes('GUSTAI') || String(g.recipient_account || '').includes('954766235')) }, { label: 'Account', val: g.recipient_account || null, check: String(g.recipient_account || '').includes('954766235') }, { label: 'Reference', val: g.reference_number || null, check: !!g.reference_number }, { label: 'Bank', val: g.bank_name || null, check: true }, { label: 'Sender', val: g.sender_name || null, check: true }, { label: 'Type', val: g.transaction_type || null, check: true }, ].map(({ label, val, check }) => (
{label}
{val ?? '—'} {val && ( {check ? '✓' : '✗'} )}
))}
{receipt.flag_reason && (
Flag Reason
{receipt.flag_reason}
)}
) : null} {/* Admin note */} {!isSettled && (
Admin Note (optional)