// ─── 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 (
}
/>
{/* 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 ? (
) : (
{receipt.receipt_path ? 'Loading image…' : 'No image uploaded'}
)}
{/* Gemini analysis */}
{receipt.status === 'pending' ? (
Gemini is analysing the receipt…
) : 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 && (
)}
{receipt.admin_note && isSettled && (
Admin Note
{receipt.admin_note}
)}
{msg && (
{msg}
)}
{/* Action buttons — hidden once settled */}
{!isSettled && (
)}
);
}
// ── Admin receipt list (main view) ────────────────────────────────────────────
function GLAdminReceipts({ app, go }) {
const [receipts, setReceipts] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [search, setSearch] = React.useState('');
const [filter, setFilter] = React.useState('all');
const [stats, setStats] = React.useState({ needsAction: 0, todayTotal: 0, todayCount: 0 });
const [selected, setSelected] = React.useState(null);
const [lastRefresh, setLastRefresh] = React.useState(null);
const FILTERS = [
{ id: 'all', label: 'All' },
{ id: 'pending', label: 'Pending' },
{ id: 'flagged', label: 'Flagged ⚠' },
{ id: 'auto_confirmed', label: 'Auto ✓' },
{ id: 'confirmed', label: 'Confirmed' },
{ id: 'rejected', label: 'Rejected' },
];
const load = React.useCallback(async () => {
try {
// Build query
let q = window.db.from('payment_receipts').select('*').order('created_at', { ascending: false });
if (filter !== 'all') q = q.eq('status', filter);
if (search.trim()) q = q.ilike('order_no', `%${search.trim()}%`);
q = q.limit(100);
const { data, error } = await q;
if (error) throw error;
setReceipts(data || []);
setLastRefresh(new Date());
// Stats
const today = new Date().toISOString().slice(0, 10);
const { data: allToday } = await window.db
.from('payment_receipts')
.select('status, amount')
.gte('created_at', today + 'T00:00:00+07:00');
if (allToday) {
const needsAction = allToday.filter(r => r.status === 'pending' || r.status === 'flagged').length;
const confirmed = allToday.filter(r => r.status === 'confirmed' || r.status === 'auto_confirmed');
const todayTotal = confirmed.reduce((s, r) => s + parseFloat(r.amount || 0), 0);
setStats({ needsAction, todayTotal, todayCount: confirmed.length });
}
} catch (e) {
console.error('[admin] Load error:', e);
} finally {
setLoading(false);
}
}, [filter, search]);
// Initial load + auto-refresh every 30s
React.useEffect(() => {
load();
const iv = setInterval(load, 30000);
return () => clearInterval(iv);
}, [load]);
// Show detail view when a receipt is selected
if (selected) {
return (
setSelected(null)}
onRefresh={() => { setSelected(null); load(); }}
/>
);
}
return (
go('me')}
center={RECEIPTS
}
right={
}
/>
{/* Stats bar */}
{[
{ label: 'Needs Action', val: stats.needsAction, color: stats.needsAction > 0 ? '#FFC107' : GL.teal },
{ label: "Today's Revenue", val: `฿${stats.todayTotal.toLocaleString()}`, color: GL.teal },
{ label: 'Confirmed Today', val: stats.todayCount, color: GL.teal },
].map(s => (
))}
{/* Search */}
{/* Filter tabs */}
{FILTERS.map(f => (
))}
{/* List */}
{loading ? (
Loading receipts…
) : receipts.length === 0 ? (
📭
No receipts found
{filter !== 'all' ? `No ${filter} receipts` : search ? `No results for "${search}"` : 'No receipts yet'}
) : (
<>
{receipts.map(r => (
))}
{lastRefresh && (
Last updated {fmtTime(lastRefresh.toISOString())} · Auto-refreshes every 30s
)}
>
)}
);
}
// ── Admin Push Notification Sender ────────────────────────────────────────────
function GLAdminPush({ onBack }) {
const TEMPLATES = [
{ label: '🍝 New Drop', title: 'New drop just landed', body: 'Fresh pasta is ready. Order now before it sells out.' },
{ label: '👋 Miss You', title: "We haven't seen you in a while", body: 'Your favourite pasta is waiting. Come back and order today.' },
{ label: '🔥 Flash Sale', title: 'Flash sale — today only', body: '20% off all orders this afternoon. Use code DROP20 at checkout.' },
{ label: '📦 Custom', title: '', body: '' },
];
const [tpl, setTpl] = React.useState(0);
const [title, setTitle] = React.useState(TEMPLATES[0].title);
const [body, setBody] = React.useState(TEMPLATES[0].body);
const [sending, setSending] = React.useState(false);
const [result, setResult] = React.useState(null);
const pickTemplate = (i) => {
setTpl(i);
setTitle(TEMPLATES[i].title);
setBody(TEMPLATES[i].body);
setResult(null);
};
const send = async () => {
if (!title.trim() || !body.trim()) return;
setSending(true); setResult(null);
try {
const res = await window.GLPush.sendToAll({ title: title.trim(), body: body.trim() });
setResult({ ok: true, sent: res?.sent ?? 0, failed: res?.failed ?? 0 });
} catch (e) {
setResult({ ok: false, error: e?.message || String(e) });
} finally { setSending(false); }
};
return (
}
/>
Push
Broadcast
Sends to all subscribed users. Use for drops, promotions, and re-engagement.
{/* Templates */}
Quick templates
{TEMPLATES.map((t, i) => (
))}
{/* Title */}
Title
setTitle(e.target.value)}
placeholder="e.g. New drop just landed"
maxLength={60}
style={{
width: '100%', boxSizing: 'border-box', border: `2px solid ${GL.ink}`,
padding: '12px 14px', fontSize: 14, fontWeight: 600, outline: 'none',
marginBottom: 16, boxShadow: `3px 3px 0 ${GL.ink}`,
}}
/>
{/* Body */}
Message
);
}
Object.assign(window, { GLAdminReceipts, GLAdminReceiptDetail, GLAdminPush });