/* 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 (
{/* 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 }
Mesin
Status
Sales Hari Ini
Trx
Suhu
Stok
Update
{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 (
{online && ● Online }
{offline && ● Offline }
{!tele && — }
{tele ? formatRp(tele.sales_today || 0) : "—"}
{tele?.transactions_today ?? "—"}
{tele?.temperature != null ? `${tele.temperature}°C` : "—"}
{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 (
navigate({ name: "admin-clients" })} variant="dark">Daftar Client
navigate({ name: "admin-machines" })} variant="primary">Status Mesin >
: navigate({ name: "machines" })} variant="primary">Kelola Mesin
}
/>
{/* Expiring alert */}
{expiringSoon.length > 0 && (
{expiringSoon.length} mesin mendekati expired
Mesin {expiringSoon[0].id} berakhir dalam {statusForMachine(expiringSoon[0]).days} hari.
navigate(isAdmin
? { name: "admin-machines" }
: { name: "machine-renew", id: expiringSoon[0].id }
)}>
{isAdmin ? "Lihat Semua" : "Perpanjang"}
)}
{/* 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 */}
navigate({ name: isAdmin ? "admin-machines" : "machines" })}
className="text-sm font-semibold text-[#FF2D55] hover:underline">
Lihat semua →
{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 && (
{outstandingInvoices.length > 0 && (
{outstandingInvoices.length} belum dibayar
)}
{myInvoices.length === 0 ? (
Belum ada invoice.
) : (
No Invoice
Periode
Nominal
Status
PDF
{myInvoices.map(inv => (
{inv.id}
{formatDateID(inv.date)}
{inv.period || "—"}
{formatRp(inv.amount)}
{inv.status}
{
try { window.exportInvoiceDoc(inv, user?.name || "—"); }
catch (e) { console.warn("PDF gagal:", e); }
}}
className="p-2 rounded-lg text-stone-400 hover:bg-stone-100 hover:text-[#FF2D55]">
))}
)}
)}
{/* 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 => (
setFilter(f.id)}
className={`px-4 py-2 rounded-full text-sm font-semibold transition ${filter === f.id ? "bg-[#1A1A1A] text-white" : "bg-white border border-stone-200 text-stone-700 hover:border-[#FF2D55] hover:text-[#FF2D55]"}`}>
{f.label}
))}
{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"}
Mulai
{formatDateID(m.contractStart)}
Berakhir
{formatDateID(m.contractEnd)}
{isMonthlyScheme(m) ? (
<>
Per bulan
{formatRp(m.monthlyRevenue)}
>
) : (
<>
Skema
{m.scheme || "Beli Putus"}
>
)}
{ e.stopPropagation(); navigate({ name: "machine-detail", id: m.id }); }}>Detail
{(s.tone === "warning" || s.tone === "danger") && (
{ e.stopPropagation(); navigate({ name: "machine-renew", id: m.id }); }}>
Perpanjang
)}
);
})}
);
}
/* ===== 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 (
navigate({ name: "machines" })} className="flex items-center gap-1.5 text-sm font-semibold text-stone-500 hover:text-[#FF2D55] mb-5">
Kembali ke Mesin
{m.model}
{m.id}
{m.type}
{s.label}
{m.scheme}
Garansi 1 Tahun
{teleBadge.label}
{(s.tone === "warning" || s.tone === "danger") && (
navigate({ name: "machine-renew", id: m.id })}>
Perpanjang Kontrak
)}
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 => (
))}
{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.
)}
{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 }) => (
);
return (
{/* Avatar card */}
{user?.name?.charAt(0)}
{user?.name}
{user?.email}
{isAdmin ? "Administrator" : `Client sejak ${user?.joined?.split(" ").slice(-2).join(" ")}`}
{user?.address && (
)}
{/* Edit profile */}
Informasi
Detail Akun
{/* Change password */}
Keamanan
Ubah Password
);
}
Object.assign(window, { OverviewView, MachinesView, MachineDetailView, ProfileView, ProgressBar, StatCard, AdminAnalyticsBlock });