/* 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 (
{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) && (
)}
{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 (
{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 (
);
}
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;