/* INDIEKOS — shared presentational components */ const { useState, useEffect, useRef, useMemo } = React; const D = window.DATA; /* ---- Pill mapping ---- */ const INV_STATUS = { paid: { pill: "pill-success", label: "Lunas" }, partial: { pill: "pill-info", label: "Sebagian" }, open: { pill: "pill-muted", label: "Open" }, overdue: { pill: "pill-danger", label: "Menunggak" }, pending: { pill: "pill-gold", label: "Menunggu verifikasi" }, reserved:{ pill: "pill-gold", label: "Reserved (DP)" }, empty: { pill: "pill-muted", label: "Kosong" }, }; function Pill({ status, label, lg, dot = true }) { const m = INV_STATUS[status] || { pill: "pill-muted", label: label || status }; return {dot && }{label || m.label}; } window.Pill = Pill; window.INV_STATUS = INV_STATUS; /* ---- Source chip (admin / penghuni / bot) ---- */ function SourceChip({ src }) { if (!src || src === "—") return ; const map = { penghuni: ["user", "Penghuni"], admin: ["shield", "Admin"], bot: ["whatsapp", "Bot WA"] }; const [ic, lbl] = map[src] || ["user", src]; return {lbl}; } window.SourceChip = SourceChip; /* ---- Avatar / monogram ---- */ function Avatar({ name, size, img }) { const cls = "avatar" + (size === "lg" ? " avatar-lg" : size === "xl" ? " avatar-xl" : ""); const initials = (name || "?").split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); return
{img ? : initials}
; } window.Avatar = Avatar; /* ---- KPI stat card ---- */ function StatCard({ label, value, unit, chip, chipIcon, sub, delta, pair, of }) { return (
{label}
{chip &&
}
{pair ? (
{value} / {of}
) : (
{value}{unit && {unit}}
)}
{delta && ( {delta.val} )} {sub}
); } window.StatCard = StatCard; /* ---- Section card wrapper ---- */ function Card({ title, sub, action, children, flush, foot, style, className }) { return (
{(title || action) && (

{title}

{sub &&
{sub}
}
{action}
)}
{children}
{foot &&
{foot}
}
); } window.Card = Card; /* ---- Progress bar (partial payment) ---- */ function PayProgress({ paid, total, status }) { const pct = Math.min(100, Math.round((paid / total) * 100)); const cls = status === "paid" ? "paid" : status === "overdue" ? "overdue" : ""; return (
{D.rp(paid)} / {D.rp(total)}
); } window.PayProgress = PayProgress; /* ---- Tabs ---- */ function Tabs({ tabs, active, onChange }) { return (
{tabs.map(t => ( ))}
); } window.Tabs = Tabs; /* ---- Empty state ---- */ function Empty({ icon, title, text, action }) { return (

{title}

{text &&

{text}

} {action}
); } window.Empty = Empty; /* ---- Skeleton rows ---- */ function SkeletonRows({ rows = 5, cols = 4 }) { return (
{Array.from({ length: rows }).map((_, i) => (
{Array.from({ length: cols - 1 }).map((_, j) =>
)}
))}
); } window.SkeletonRows = SkeletonRows; /* ---- Revenue vs Expense mini bar chart (SVG) ---- */ function RevExpChart({ data, height = 170 }) { const max = (Math.max(0, ...data.map(d => Math.max(d.rev || 0, d.exp || 0))) * 1.1) || 1; const W = 100, gap = W / data.length; const bw = gap * 0.28; return (
{[0.25, 0.5, 0.75].map(g => )} {data.map((d, i) => { const cx = i * gap + gap / 2; const rh = ((d.rev || 0) / max) * 50, eh = ((d.exp || 0) / max) * 50; return ( ); })}
{data.map((d, i) => {d.m})}
Revenue Expense
); } window.RevExpChart = RevExpChart; /* ---- Donut (occupancy) ---- */ function Donut({ pct, size = 120, label, sub, color }) { const r = 42, c = 2 * Math.PI * r, off = c * (1 - pct / 100); return (
{label}
{sub &&
{sub}
}
); } window.Donut = Donut; /* ---- Toast ---- */ function useToast() { const [toasts, setToasts] = useState([]); const push = (msg, kind = "success") => { const id = Math.random(); setToasts(t => [...t, { id, msg, kind }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 2800); }; const node = (
{toasts.map(t => (
{t.msg}
))}
); return [node, push]; } window.useToast = useToast; const _toastStyle = document.createElement("style"); _toastStyle.textContent = "@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}"; document.head.appendChild(_toastStyle); /* ---- PWA install prompt (Android/desktop beforeinstallprompt + iOS hint) ---- */ function InstallPrompt({ appName }) { const [evt, setEvt] = useState(null); const [show, setShow] = useState(false); const [ios, setIos] = useState(false); useEffect(() => { if (sessionStorage.getItem("iruma_install_dismiss")) return; const standalone = window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone; if (standalone) return; const onBip = (e) => { e.preventDefault(); setEvt(e); setShow(true); }; window.addEventListener("beforeinstallprompt", onBip); const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent); if (isIos) { setIos(true); setTimeout(() => setShow(true), 1400); } return () => window.removeEventListener("beforeinstallprompt", onBip); }, []); const dismiss = () => { setShow(false); sessionStorage.setItem("iruma_install_dismiss", "1"); }; const install = async () => { if (evt) { evt.prompt(); await evt.userChoice; setShow(false); } }; if (!show) return null; return (
Pasang {appName}
{ios ? "Ketuk Bagikan ⎙ → \u201CTambah ke Layar Utama\u201D" : "Akses cepat dari layar utama, jalan offline."}
{!ios && }
); } window.InstallPrompt = InstallPrompt;