/* ============================================================ Telemetron Integration - Base URL: https://api.telemetron.net - Auth: POST /auth/ (grant types: password | auth_code | refresh_token) - Token cached in sessionStorage - Credentials persisted in yappari_settings (key: 'telemetron') - Auto-sync every 5 minutes while admin tab is open ============================================================ */ const { useState: useStateTele, useEffect: useEffectTele } = React; const TELEMETRON_BASE = "https://api.telemetron.net"; const TELE_TOKEN_KEY = "yappari_telemetron_token"; // ── Settings shape ────────────────────────────────────────── // { client_id, client_secret, username, password, // machine_map: { telemetronVMId: localMachineId, ... } } const telemetron = { // — token — getCachedToken() { try { const raw = sessionStorage.getItem(TELE_TOKEN_KEY); if (!raw) return null; const t = JSON.parse(raw); if (t.expires_at && Date.now() > t.expires_at - 30_000) return null; // expire 30s early return t; } catch { return null; } }, cacheToken(t) { const enriched = { ...t, expires_at: Date.now() + (Number(t.expires_in) || 3600) * 1000, }; sessionStorage.setItem(TELE_TOKEN_KEY, JSON.stringify(enriched)); return enriched; }, clearToken() { sessionStorage.removeItem(TELE_TOKEN_KEY); }, // — credentials — async loadCreds() { return (await db.getSetting("telemetron")) || {}; }, async saveCreds(creds) { return db.setSetting("telemetron", creds); }, // — auth — async authenticate(creds) { if (!creds.client_id || !creds.client_secret || !creds.username || !creds.password) { throw new Error("Lengkapi semua field: client_id, client_secret, username, password."); } const body = new URLSearchParams({ grant_type: "password", client_id: creds.client_id, client_secret: creds.client_secret, username: creds.username, password: creds.password, }); const res = await fetch(`${TELEMETRON_BASE}/auth/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.message || data.error_description || `Auth gagal (${res.status})`); if (!data.access_token) throw new Error("Auth respons tidak mengandung access_token."); return telemetron.cacheToken(data); }, async ensureToken() { const cached = telemetron.getCachedToken(); if (cached) return cached; const creds = await telemetron.loadCreds(); return telemetron.authenticate(creds); }, async apiGet(path) { const t = await telemetron.ensureToken(); const res = await fetch(`${TELEMETRON_BASE}${path}`, { headers: { "Authorization": `Bearer ${t.access_token}`, "Accept": "application/json" }, }); if (res.status === 401) { telemetron.clearToken(); const fresh = await telemetron.ensureToken(); const res2 = await fetch(`${TELEMETRON_BASE}${path}`, { headers: { "Authorization": `Bearer ${fresh.access_token}`, "Accept": "application/json" }, }); if (!res2.ok) throw new Error(`API ${path} → ${res2.status}`); return res2.json(); } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`API ${path} → ${res.status}: ${text.slice(0, 200)}`); } return res.json(); }, // — fetchers — async listMachines() { return telemetron.apiGet("/v2/vending_machines"); }, async machineDetail(id) { return telemetron.apiGet(`/v2/vending_machines/${id}`); }, async salesSummary(query = "") { return telemetron.apiGet(`/v2/stats/vends/summary${query}`); }, async machineEvents(id, q = "") { return telemetron.apiGet(`/v2/vending_machines/${id}/events${q}`); }, // — sync — async syncAll() { const creds = await telemetron.loadCreds(); const map = creds.machine_map || {}; const list = await telemetron.listMachines(); const remote = list?.data || list?.vending_machines || list || []; const today = new Date(); today.setHours(0,0,0,0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const range = `?date_from=${today.toISOString().slice(0,10)}&date_to=${tomorrow.toISOString().slice(0,10)}`; let summary; try { summary = await telemetron.salesSummary(range); } catch { summary = null; } // Telemetron `/stats/vends/summary` returns AGGREGATE (no per-VM split) — e.g. // [{ "number": , "value": }] // so we can only attribute it back to a single VM if the user has exactly one // mapped machine. Otherwise sales_today/transactions_today are left null. const summaryRows = summary?.data || summary?.summary || summary || []; const summaryArr = Array.isArray(summaryRows) ? summaryRows : []; const perMachineSales = {}; let aggregateTotal = null; summaryArr.forEach(row => { const teleId = row.vending_machine_id ?? row.vm_id ?? row.id; const sales = Number(row.amount ?? row.total ?? row.revenue ?? row.value ?? 0); const txns = Number(row.count ?? row.vends ?? row.transactions ?? row.number ?? 0); if (teleId != null) perMachineSales[teleId] = { sales, transactions: txns }; else aggregateTotal = { sales, transactions: txns }; }); const mappedCount = Object.keys(map).length; const rows = []; for (const vm of remote) { const teleId = vm.id ?? vm.vm_id; const localId = map[teleId]; if (!localId) continue; // skip unmapped // Per-VM stats if available; else attribute single-machine aggregate. let stats = perMachineSales[teleId] || null; if (!stats && aggregateTotal && mappedCount === 1) stats = aggregateTotal; // last_event_dates is the canonical Telemetron v2 shape; older fields kept as fallback. const lastSeen = vm.last_event_dates?.last_report || vm.last_event_dates?.last_sale || vm.last_event_dates?.last_acquired_report || vm.last_event_at || vm.last_seen || vm.updated_at || null; // Vending machines may go quiet between sales; widen the online window to 60 min. const online = lastSeen ? (Date.now() - new Date(lastSeen).getTime() < 60 * 60_000) : (vm.online ?? null); rows.push({ machine_id: localId, sales_today: stats?.sales ?? null, transactions_today: stats?.transactions ?? null, temperature: vm.temperature ?? vm.cabinet_temperature ?? null, is_online: online, stock_level: vm.stock_level ?? null, recorded_at: new Date().toISOString(), }); } if (rows.length) await db.insertTelemetry(rows); return { remoteCount: remote.length, syncedCount: rows.length, unmappedCount: remote.length - rows.length, raw: { machines: remote, summary: summaryArr }, }; }, }; /* ============================================================ ADMIN: TELEMETRON SETTINGS VIEW ============================================================ */ function TelemetronSettingsView() { const { toast, allMachines } = useApp(); const [creds, setCreds] = useStateTele({ client_id: "a1dd429f-712d-4d6c-be13-be57cfe38c4f", client_secret: "", username: "", password: "", machine_map: {}, }); const [remote, setRemote] = useStateTele([]); // [{id, name, ...}] const [loading, setLoading] = useStateTele(true); const [busy, setBusy] = useStateTele(false); const [lastSync, setLastSync] = useStateTele(null); const [autoSync, setAutoSync] = useStateTele(false); useEffectTele(() => { let cancelled = false; (async () => { try { const c = await telemetron.loadCreds(); if (!cancelled && c && Object.keys(c).length) { setCreds(prev => ({ ...prev, ...c, machine_map: c.machine_map || {} })); } } catch (e) { console.warn("loadCreds:", e); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, []); // Auto-sync interval (only while view mounted + toggle on) useEffectTele(() => { if (!autoSync) return; const id = setInterval(() => { runSync(true); }, 5 * 60_000); return () => clearInterval(id); }, [autoSync]); async function saveCreds() { setBusy(true); try { await telemetron.saveCreds(creds); toast("Credentials Telemetron tersimpan.", "success"); } catch (e) { toast("Gagal simpan: " + e.message, "danger"); } finally { setBusy(false); } } async function testAuth() { setBusy(true); try { telemetron.clearToken(); await telemetron.saveCreds(creds); const tok = await telemetron.authenticate(creds); toast(`Login berhasil. Token expires in ${tok.expires_in || "?"}s.`, "success"); } catch (e) { toast("Login gagal: " + e.message, "danger"); } finally { setBusy(false); } } async function fetchRemote() { setBusy(true); try { await telemetron.saveCreds(creds); const list = await telemetron.listMachines(); const arr = list?.data || list?.vending_machines || list || []; setRemote(Array.isArray(arr) ? arr : []); toast(`Ditemukan ${arr.length} mesin di Telemetron.`, "success"); } catch (e) { toast("Gagal ambil daftar mesin: " + e.message, "danger"); } finally { setBusy(false); } } async function runSync(silent = false) { setBusy(true); try { const r = await telemetron.syncAll(); setLastSync(new Date()); // Log raw responses so we can verify field-name mapping when sales data looks empty. console.log("[telemetron] raw machines:", r.raw?.machines); console.log("[telemetron] raw summary:", r.raw?.summary); if (!silent) toast(`Sync OK · ${r.syncedCount}/${r.remoteCount} mesin (${r.unmappedCount} belum di-map).`, "success"); } catch (e) { if (!silent) toast("Sync gagal: " + e.message, "danger"); console.warn("syncAll error:", e); } finally { setBusy(false); } } function setMap(teleId, localId) { const next = { ...(creds.machine_map || {}) }; if (localId) next[teleId] = localId; else delete next[teleId]; setCreds({ ...creds, machine_map: next }); } if (loading) return (
Memuat pengaturan…
); return (
OAuth Credentials
setCreds({...creds, client_id: v})}/> setCreds({...creds, client_secret: v})}/> setCreds({...creds, username: v})}/> setCreds({...creds, password: v})}/>
Grant type yang didukung Telemetron: password, auth_code, refresh_token. Karena tidak ada client_credentials, akun service / username+password tetap dibutuhkan. Token disimpan di sessionStorage (hilang saat tab ditutup).
Sinkronisasi Telemetry
{lastSync ? `Sync terakhir: ${lastSync.toLocaleTimeString("id-ID")}` : "Belum pernah sync."}
Catatan: auto-sync hanya berjalan selama tab dashboard ini terbuka. Untuk sync background 24/7, deploy worker Node.js terpisah (rekomendasi: bridge service à la accurate-bridge).
Machine Mapping
Telemetron → Lokal
{remote.length} mesin Telemetron
{remote.length === 0 ? (
Klik Ambil Daftar Mesin untuk memuat daftar dari Telemetron.
) : ( {remote.map(vm => { const teleId = vm.id ?? vm.vm_id; const mapped = creds.machine_map?.[teleId] || ""; return ( ); })}
Telemetron ID Nama Mesin Lokal
{String(teleId)} {vm.name || vm.label || vm.title || "—"}
)} {remote.length > 0 && (
)}
); } Object.assign(window, { telemetron, TelemetronSettingsView });