/* Views: Overview, Machines list, MachineDetail, Profile */ const { useState: useStateViews, useMemo: useMemoViews, useEffect: useEffectViews } = React; // Per-category safe temperature window. Outside this range raises an alert in // the admin overview. Coffee machines don't have an internal cabinet temp // constraint exposed via telemetry, so we leave them unmonitored (null). const TEMP_RANGES = { combo: { min: 2, max: 8, label: "2–8°C" }, frozen: { min: -25, max: -5, label: "-25 ke -5°C" }, coffee: null, }; // Skema yang TIDAK punya tagihan bulanan — supaya "Per Bulan / Breakdown Biaya" // disembunyikan. Beli Putus = client beli mesin outright; Kerjasama Lokasi = // revenue-sharing tanpa sewa flat. const NON_MONTHLY_SCHEMES = new Set(["Beli Putus", "Kerjasama Lokasi"]); const isMonthlyScheme = (m) => !NON_MONTHLY_SCHEMES.has(m?.scheme) && Number(m?.monthlyRevenue || 0) > 0; function invoiceStatusTone(s) { if (s === "Lunas") return "success"; if (s === "Outstanding") return "danger"; return "warning"; } /* ===== Stat card ===== */ function StatCard({ label, value, sub, tone, icon, accent }) { return (
{label}
{value}
{sub &&
{sub}
}
{tone && {tone.label}}
); } /* ===== ADMIN ANALYTICS BLOCK ===== */ function AdminAnalyticsBlock({ machines, invoices }) { const [latestTele, setLatestTele] = useStateViews({}); const [sales7d, setSales7d] = useStateViews([]); const [loading, setLoading] = useStateViews(true); useEffectViews(() => { let cancelled = false; (async () => { try { const latest = await db.latestTelemetry(); if (cancelled) return; setLatestTele(latest); // 7-day sales aggregate from telemetry (max sales_today per machine per day) const since = new Date(TODAY); since.setDate(since.getDate() - 6); since.setHours(0,0,0,0); const { data, error } = await sb.from("yappari_telemetry") .select("machine_id, sales_today, recorded_at") .gte("recorded_at", since.toISOString()) .order("recorded_at", { ascending: true }); if (error) throw error; const bucket = {}; // { 'YYYY-MM-DD': { machineId: maxSales } } (data || []).forEach(r => { const day = r.recorded_at.slice(0, 10); bucket[day] = bucket[day] || {}; bucket[day][r.machine_id] = Math.max(bucket[day][r.machine_id] || 0, Number(r.sales_today) || 0); }); const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(TODAY); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0,10); const total = Object.values(bucket[key] || {}).reduce((s, v) => s + v, 0); days.push({ date: key, label: d.toLocaleDateString("id-ID", { weekday: "short" }), total }); } if (!cancelled) setSales7d(days); } catch (e) { console.warn("AdminAnalyticsBlock fetch failed:", e); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, []); // ── Revenue this month vs last month (from invoices) ── const { thisMonth, lastMonth, deltaPct } = useMemoViews(() => { const t = TODAY; const tYear = t.getFullYear(), tMonth = t.getMonth(); let thisMonth = 0, lastMonth = 0; invoices.forEach(inv => { if (inv.status !== "Lunas") return; const d = new Date(inv.date); if (d.getFullYear() === tYear && d.getMonth() === tMonth) thisMonth += inv.amount; else if ((tMonth === 0 && d.getFullYear() === tYear - 1 && d.getMonth() === 11) || (d.getFullYear() === tYear && d.getMonth() === tMonth - 1)) lastMonth += inv.amount; }); const deltaPct = lastMonth > 0 ? ((thisMonth - lastMonth) / lastMonth) * 100 : null; return { thisMonth, lastMonth, deltaPct }; }, [invoices]); // ── Alerts: offline machines + abnormal temp ── const alerts = useMemoViews(() => { const out = []; machines.forEach(m => { const tele = latestTele[m.id]; if (!tele) return; // no data yet if (tele.is_online === false) { out.push({ machine: m, kind: "offline", detail: `Tidak online sejak ${new Date(tele.recorded_at).toLocaleString("id-ID")}` }); } const range = TEMP_RANGES[m.category]; if (range && tele.temperature != null) { const t = Number(tele.temperature); if (t < range.min || t > range.max) { out.push({ machine: m, kind: "temp", detail: `Suhu ${t}°C (aman: ${range.label})` }); } } }); return out; }, [machines, latestTele]); const hasTelemetry = Object.keys(latestTele).length > 0; const max7d = Math.max(1, ...sales7d.map(d => d.total)); const chartW = 700, chartH = 180, padL = 36, padB = 28, padT = 12, padR = 12; const innerW = chartW - padL - padR, innerH = chartH - padT - padB; const barW = sales7d.length ? innerW / sales7d.length * 0.65 : 0; const stepX = sales7d.length ? innerW / sales7d.length : 0; return (
Analytics

Insight Admin

{/* Revenue month-over-month */}
Revenue Bulan Ini
{formatRp(thisMonth)}
{deltaPct === null ? ( Belum ada perbandingan ) : deltaPct >= 0 ? ( +{deltaPct.toFixed(1)}% ) : ( {deltaPct.toFixed(1)}% )} vs bulan lalu ({formatRp(lastMonth)})
{/* 7-day sales chart */}
Sales 7 Hari
Total: {formatRp(sales7d.reduce((s, d) => s + d.total, 0))}
sumber: telemetry
{loading ? (
Memuat…
) : !hasTelemetry || sales7d.every(d => d.total === 0) ? (
Belum ada data telemetry.
Aktifkan sync Telemetron untuk melihat tren penjualan.
) : ( {/* y gridlines */} {[0.25, 0.5, 0.75, 1].map((p, i) => ( ))} {/* y labels */} {[0, 0.5, 1].map((p, i) => ( {Math.round(max7d * p / 1000)}k ))} {/* bars */} {sales7d.map((d, i) => { const x = padL + stepX * i + (stepX - barW) / 2; const h = (d.total / max7d) * innerH; const y = padT + innerH - h; return ( {d.label} {d.total > 0 && ( {Math.round(d.total / 1000)}k )} ); })} )}
{/* Alerts */} {alerts.length > 0 && (
{alerts.length} Peringatan Mesin
{alerts.slice(0, 8).map((a, i) => (
{a.machine.id}
{a.detail}
{a.kind === "offline" ? "Offline" : "Suhu"}
))}
)} {/* Online/offline machine table */}
Status Realtime
Telemetry Mesin
{!hasTelemetry && Belum ada data}
{machines.map(m => { const tele = latestTele[m.id]; const online = tele?.is_online === true; const offline = tele && tele.is_online === false; const range = TEMP_RANGES[m.category]; const tempBad = range && tele?.temperature != null && (Number(tele.temperature) < range.min || Number(tele.temperature) > range.max); return ( ); })}
Mesin Status Sales Hari Ini Trx Suhu Stok Update
{m.id}
{m.location}
{online && ● Online} {offline && ● Offline} {!tele && } {tele ? formatRp(tele.sales_today || 0) : "—"} {tele?.transactions_today ?? "—"} {tele?.stock_level != null ? `${tele.stock_level}%` : "—"} {tele ? new Date(tele.recorded_at).toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" }) : "—"}
); } /* ===== OVERVIEW ===== */ function OverviewView() { const { user, isAdmin, machines, allMachines, navigate, users, invoices } = useApp(); /* admin sees all machines, client sees their own */ const myMachines = isAdmin ? allMachines : machines; const active = myMachines.filter(m => statusForMachine(m).tone !== "danger").length; const expiringSoon = myMachines.filter(m => statusForMachine(m).tone === "warning"); const totalMonthly = myMachines.reduce((s, m) => s + m.monthlyRevenue, 0); const totalSales = myMachines.reduce((s, m) => s + m.salesThisMonth, 0); const totalTx = myMachines.reduce((s, m) => s + m.transactionsThisMonth, 0); const clients = users.filter(u => u.role === "client"); // Client invoices — used both for the Tagihan StatCard and the "Invoice Saya" table. const myInvoices = useMemoViews( () => isAdmin ? [] : invoices.filter(i => i.clientId === user?.id), [invoices, isAdmin, user?.id] ); const outstandingInvoices = myInvoices.filter(i => i.status !== "Lunas"); const totalOutstanding = outstandingInvoices.reduce((s, i) => s + Number(i.amount || 0), 0); const nextBilling = myMachines.length > 0 ? myMachines .map(m => ({ machine: m, date: m.contractEnd, amount: m.monthlyRevenue })) .sort((a, b) => new Date(a.date) - new Date(b.date))[0] : null; return (
: } /> {/* Expiring alert */} {expiringSoon.length > 0 && (
{expiringSoon.length} mesin mendekati expired
Mesin {expiringSoon[0].id} berakhir dalam {statusForMachine(expiringSoon[0]).days} hari.
)} {/* Summary grid */}
{isAdmin && ( )} {isAdmin ? (nextBilling && ( )) : ( ) } 0 ? "Perhatian" : "Normal"} sub={expiringSoon.length > 0 ? `${expiringSoon.length} mesin expiring` : "Semua mesin oke"} icon="shield" accent={expiringSoon.length > 0 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}/>
{/* Admin-only analytics */} {isAdmin && ( )} {/* Machines mini-list */}
Mesin

Status Mesin

{myMachines.slice(0, 5).map(m => { const s = statusForMachine(m); const client = isAdmin ? USERS.find(u => u.id === m.clientId) : null; return ( navigate(isAdmin ? { name: "admin-machines" } : { name: "machine-detail", id: m.id })}>
{m.model}
{m.id}
{isAdmin && client &&
{client.name}
}
{m.location}
{isMonthlyScheme(m) ? ( <>
Per bulan
{formatRp(m.monthlyRevenue)}
) : ( <>
Skema
{m.scheme || "Beli Putus"}
)}
{s.tone === "success" ? "Aktif" : s.tone === "warning" ? "Akan Berakhir" : "Expired"}
{s.label}
); })}
{/* Client invoices — only for clients (admins use the Invoices view) */} {!isAdmin && (
Tagihan

Invoice Saya

{outstandingInvoices.length > 0 && ( {outstandingInvoices.length} belum dibayar )}
{myInvoices.length === 0 ? (
Belum ada invoice.
) : (
{myInvoices.map(inv => ( ))}
No Invoice Periode Nominal Status PDF
{inv.id}
{formatDateID(inv.date)}
{inv.period || "—"} {formatRp(inv.amount)} {inv.status}
)}
)} {/* Recent payments */}
Aktivitas

Pembayaran Terbaru

Tanggal
Mesin
Metode
Nominal
Status
{myMachines.flatMap(m => m.payments.slice(0, 2).map(p => ({ ...p, machine: m }))).slice(0, 6).map((p, i) => (
{formatDateID(p.date)}
{p.machine.id}
{p.machine.model}
{p.method}
{formatRp(p.amount)}
Lunas
))}
); } /* ===== CLIENT: MACHINES LIST ===== */ function MachinesView() { const { machines, navigate } = useApp(); const [filter, setFilter] = useStateViews("all"); const filtered = filter === "all" ? machines : machines.filter(m => { const s = statusForMachine(m).tone; return (filter === "active" && s === "success") || (filter === "expiring" && s === "warning") || (filter === "expired" && s === "danger"); }); return (
{[ { id: "all", label: "Semua" }, { id: "active", label: "Aktif" }, { id: "expiring", label: "Akan Berakhir" }, { id: "expired", label: "Expired" }, ].map(f => ( ))}
{filtered.map(m => { const s = statusForMachine(m); return ( navigate({ name: "machine-detail", id: m.id })}>
{m.model}
{m.id}
{m.type}
{s.tone === "success" ? "Aktif" : s.tone === "warning" ? "Akan Berakhir" : "Expired"}
Lokasi
{m.location}
Skema
{m.scheme}
Mulai
{formatDateID(m.contractStart)}
Berakhir
{formatDateID(m.contractEnd)}
{isMonthlyScheme(m) ? ( <>
Per bulan
{formatRp(m.monthlyRevenue)}
) : ( <>
Skema
{m.scheme || "Beli Putus"}
)}
{(s.tone === "warning" || s.tone === "danger") && ( )}
); })}
); } /* ===== CLIENT: MACHINE DETAIL ===== */ function MachineDetailView() { const { route, machines, navigate } = useApp(); const m = machines.find(x => x.id === route.id) || machines[0]; const s = statusForMachine(m); const monthly = isMonthlyScheme(m); // ── Telemetron connection + latest telemetry for this machine ────────────── const [teleStatus, setTeleStatus] = useStateViews({ loading: true, mapped: false, latest: null }); useEffectViews(() => { let cancelled = false; (async () => { try { const [creds, latest] = await Promise.all([ db.getSetting("telemetron").catch(() => null), db.latestTelemetry().catch(() => ({})), ]); if (cancelled) return; const map = creds?.machine_map || {}; const mapped = Object.values(map).some(v => String(v) === String(m.id)); setTeleStatus({ loading: false, mapped, latest: latest?.[m.id] || null }); } catch (e) { if (!cancelled) setTeleStatus({ loading: false, mapped: false, latest: null }); } })(); return () => { cancelled = true; }; }, [m.id]); const teleConnected = teleStatus.mapped && !!teleStatus.latest; const teleBadge = teleStatus.loading ? { tone: "neutral", label: "Telemetron: Memuat…" } : teleConnected ? { tone: "success", label: "Telemetron: Terhubung" } : { tone: "neutral", label: "Telemetron: Belum Sinkron" }; const breakdown = [ { label: "Sewa Pokok", val: m.rates.sewa, active: true }, { label: "Cloud Server / Dashboard", val: m.rates.cloud, active: m.addons.cloud }, { label: "Jasa Refill", val: m.rates.refill, active: m.addons.refill }, { label: "Cloud API", val: 100000, active: m.addons.cloudApi }, ]; const total = breakdown.filter(b => b.active).reduce((a, b) => a + b.val, 0); return (
{m.model}

{m.id}

{m.type}
{s.label} {m.scheme} Garansi 1 Tahun {teleBadge.label}
{(s.tone === "warning" || s.tone === "danger") && ( )}
Kontrak
{[ { label: "Skema", val: m.scheme }, { label: "Mulai", val: formatDateID(m.contractStart) }, { label: "Berakhir", val: formatDateID(m.contractEnd) }, { label: "Lokasi", val: m.location }, ].map(item => (
{item.label}
{item.val}
))}
{monthly ? (
Breakdown Biaya
Tagihan Bulanan
Total / bulan
{formatRp(total)}
{breakdown.map((b, i) => (
{b.label}
{!b.active &&
Tidak aktif
}
{formatRp(b.val)}/bln
))}
) : (
Skema Pembayaran
{m.scheme || "Beli Putus"}

Mesin ini menggunakan skema {m.scheme || "Beli Putus"} — tidak ada tagihan sewa bulanan.

)}
Riwayat
Pembayaran
{m.payments.map((p, i) => (
{formatDateID(p.date)}
via {p.method}
{formatRp(p.amount)}
{p.status}
))}
Performa Bulan Ini
{teleConnected ? "Terhubung" : "Belum Sinkron"}
{teleConnected ? ( <>
{formatRp(m.salesThisMonth)}
{Number(m.transactionsThisMonth || 0).toLocaleString("id-ID")} transaksi
) : (
Data penjualan akan muncul setelah Telemetron tersinkronisasi. {!teleStatus.mapped && ( Mesin belum di-mapping. Hubungkan di menu Admin → Telemetron Settings. )}
)}
{["QRIS","OVO","GoPay","DANA"].map(t => ( {t} ))}
保証
Garansi Mesin
  • Sparepart 1 tahun
  • Service remote gratis
  • Response time < 24 jam
Butuh bantuan?
Hubungi Ines
0897 604 6500
); } /* Progress bar */ function ProgressBar({ machine }) { const start = new Date(machine.contractStart).getTime(); const end = new Date(machine.contractEnd).getTime(); const now = TODAY.getTime(); const pct = end > start ? Math.min(Math.max((now - start) / (end - start) * 100, 0), 100) : 0; const days = Math.max(0, Math.round((end - now) / 86400000)); const tone = pct > 90 ? "danger" : pct > 80 ? "warning" : "success"; const bar = tone === "danger" ? "bg-rose-500" : tone === "warning" ? "bg-amber-500" : "bg-emerald-500"; return (
Progress kontrak {Math.round(pct)}% · {days} hari tersisa
{formatDateID(machine.contractStart)} {formatDateID(machine.contractEnd)}
); } /* ===== PROFILE ===== */ function ProfileView() { const { user, setUsers, toast, isAdmin } = useApp(); const [profile, setProfile] = useStateViews({ ...user }); const [pw, setPw] = useStateViews({ old: "", n1: "", n2: "" }); const [showOld, setShowOld] = useStateViews(false); const [showN1, setShowN1] = useStateViews(false); const [showN2, setShowN2] = useStateViews(false); function saveProfile(e) { e.preventDefault(); const updated = { ...user, ...profile }; setUsers && setUsers(prev => prev.map(u => u.id === updated.id ? updated : u)); toast("Profil berhasil diupdate.", "success"); db.upsertUser(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function changePassword(e) { e.preventDefault(); if (!pw.old) { toast("Masukkan password lama.", "danger"); return; } if (pw.old !== user.password) { toast("Password lama tidak cocok.", "danger"); return; } if (pw.n1.length < 6) { toast("Password baru minimal 6 karakter.", "danger"); return; } if (pw.n1 !== pw.n2) { toast("Konfirmasi password tidak cocok.", "danger"); return; } const updated = { ...user, password: pw.n1 }; setUsers && setUsers(prev => prev.map(u => u.id === updated.id ? updated : u)); setPw({ old: "", n1: "", n2: "" }); toast("Password berhasil diubah.", "success"); db.upsertUser(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } const PwField = ({ label, val, onChange, show, setShow }) => (
onChange(e.target.value)} className="w-full px-4 py-2.5 pr-14 rounded-xl border border-stone-300 bg-white text-[#1A1A1A] focus:border-[#FF2D55] focus:ring-2 focus:ring-pink-100 outline-none transition" placeholder="••••••••" />
); return (
{/* Avatar card */}
{user?.name?.charAt(0)}
{user?.name}
{user?.email}
{isAdmin ? "Administrator" : `Client sejak ${user?.joined?.split(" ").slice(-2).join(" ")}`} {user?.address && (
Alamat
{user.address}
)}
{/* Edit profile */}
Informasi

Detail Akun

setProfile({ ...profile, name: v })}/> setProfile({ ...profile, email: v })}/> setProfile({ ...profile, phone: v })}/> {profile.address !== undefined && ( setProfile({ ...profile, address: v })} full/> )}
{/* Change password */}
Keamanan

Ubah Password

setPw({ ...pw, old: v })} show={showOld} setShow={setShowOld}/>
setPw({ ...pw, n1: v })} show={showN1} setShow={setShowN1}/> setPw({ ...pw, n2: v })} show={showN2} setShow={setShowN2}/>
Password minimal 6 karakter.
); } Object.assign(window, { OverviewView, MachinesView, MachineDetailView, ProfileView, ProgressBar, StatCard, AdminAnalyticsBlock });