// screens-admin-more.jsx — More menu + all secondary CRUD screens
// Packages · Class types · Studios · Accounts · Payments · Reports
const { fmtIDR, findClass, findCoach } = window.NADI_HELPERS;
function AdminMoreScreen({ admin, openSection, onExitAdmin, showToast }) {
return (
{isNew ? "new pack" : `pack · #${pkg.id}`}
{isNew ? "New package." : pkg.name}
setClasses(parseInt(v, 10) || 0)} type="number" />
setValidity(parseInt(v, 10) || 0)} type="number" />
setPrice(parseInt(v, 10) || 0)} type="number" />
badge (optional)
{["","Most popular","Best value","First-timers"].map((b) => (
setBadge(b)}>{b ? b.toLowerCase() : "no badge"}
))}
preview
{name || "Untitled"}
{fmtIDR(price)}
{classes} class{classes === 1 ? "" : "es"} · {fmtIDR(Math.round(price / Math.max(1, classes)))}/class · {validity}d valid
{!isNew && (
)}
);
}
// ── CLASS TYPES CRUD ─────────────────────────────────────────────
function AdminClassTypesScreen({ onBack, showToast }) {
const [editing, setEditing] = useState(null);
return (
{isNew ? "new class type" : `class type · #${cls.id}`}
{isNew ? "New class." : cls.name}
setDuration(parseInt(v, 10) || 0)} type="number" />
level
{["Beginner","All levels","Intermediate","Advanced"].map((l) => (
setLevel(l)}>{l.toLowerCase()}
))}
accent
{[["primary","Terracotta","var(--primary)"],["accent","Sage","var(--accent)"],["muted","Muted","var(--muted)"]].map(([id, lbl, c]) => (
))}
{!isNew && (
)}
);
}
// ── STUDIOS CRUD ─────────────────────────────────────────────────
function AdminStudiosScreen({ onBack, showToast }) {
return (
{/* Weekly */}
{/* Coach payouts */}
coach payouts · {new Date().toLocaleDateString("en-GB", { month: "short" }).toLowerCase()}
{fmtIDR(window.NADI_ADMIN.PAYOUTS.filter((p) => !p.paid).reduce((s, p) => {
const co = findCoach(p.coachId);
const currentRate = (co && co.rate) || p.rate || 200000;
return s + (p.classes * currentRate);
}, 0))} owed
{window.NADI_ADMIN.PAYOUTS.map((p, i) => {
const co = findCoach(p.coachId) || { name: "Unknown", rate: 200000 };
const currentRate = co.rate || p.rate || 200000;
const currentTotal = p.classes * currentRate;
return (
);
})}
{/* Churn */}
churn watch
{window.NADI_ADMIN.CHURN_LIST.length} members lapsed
{window.NADI_ADMIN.CHURN_LIST.map((m, i) => (
))}
{/* Class fill rate */}
fill rate · by class
{window.NADI.CLASS_TYPES.slice(0, 6).map((c, i) => {
const sessions = window.NADI.SCHEDULE.filter((s) => s.classId === c.id);
if (sessions.length === 0) return null;
const booked = sessions.reduce((s, x) => s + x.booked, 0);
const cap = sessions.reduce((s, x) => s + x.capacity, 0);
const pct = Math.round((booked / cap) * 100);
return (
{c.name}
85 ? "var(--primary)" : "var(--ink-soft)"}>{pct}%
85 ? "var(--primary)" : "var(--accent)" }} />
);
})}
);
}
// ── BANK ACCOUNTS CRUD ──────────────────────────────────────────
function AdminBankAccountsScreen({ onBack, showToast }) {
const [, force] = useState(0);
const [editing, setEditing] = useState(null); // bank or "new"
const banks = window.NADI_ADMIN.BANK_ACCOUNTS;
const toggleActive = (b) => {
b.active = !b.active;
force((n) => n + 1);
showToast && showToast(b.active ? `${b.bank} enabled.` : `${b.bank} hidden from members.`);
};
return (
setEditing("new")} addLabel="+ new account" />
Accounts marked active appear in the members' bank-transfer checkout. Inactive accounts are kept on file but hidden.
{banks.map((b) => (
{b.active ? "shown to members" : "hidden from members"}
))}
setEditing(null)} maxHeight="90%">
setEditing(null)} showToast={showToast} />
);
}
function BankAccountEditor({ bank, onClose, showToast }) {
const isNew = !bank;
const [bankName, setBankName] = useState(bank?.bank || "");
const [name, setName] = useState(bank?.name || "Nadi Yoga Studio");
const [number, setNumber] = useState(bank?.number || "");
const [branch, setBranch] = useState(bank?.branch || "Tangerang");
const [active, setActive] = useState(bank ? !!bank.active : true);
const save = () => {
if (isNew) {
const id = "b" + (window.NADI_ADMIN.BANK_ACCOUNTS.length + 1);
window.NADI_ADMIN.BANK_ACCOUNTS.push({ id, bank: bankName, name, number, branch, active });
showToast && showToast("Bank account added.");
} else {
bank.bank = bankName; bank.name = name; bank.number = number; bank.branch = branch; bank.active = active;
showToast && showToast("Bank account saved.");
}
onClose();
};
const archive = () => {
if (!bank) return;
const list = window.NADI_ADMIN.BANK_ACCOUNTS;
const i = list.indexOf(bank);
if (i >= 0) list.splice(i, 1);
showToast && showToast("Bank account removed.");
onClose();
};
return (
{isNew ? "new account" : `bank · #${bank.id}`}
{isNew ? "New account." : bank.bank}
bank
{["BCA","Mandiri","BNI","BRI","Permata","CIMB","OCBC","Jago"].map((bnk) => (
setBankName(bnk)}>{bnk.toLowerCase()}
))}
setBankName(e.target.value)} placeholder="bank name"
style={{
marginTop: 10, width: "100%", padding: "10px 12px",
border: "1px solid var(--rule)", background: "var(--bg)",
fontFamily: "Newsreader, serif", fontSize: 15, color: "var(--ink)",
}} />
Active
shown to members on checkout
member preview
{bankName || "Bank"}
{(branch || "—").toLowerCase()}
{number || "0000 0000 0000"}
a/n {(name || "—").toLowerCase()}
{!isNew && (
)}
);
}
// ── PACK PURCHASES CONFIRMATION ─────────────────────────────────
function AdminPackPurchasesScreen({ onBack, openMember, showToast }) {
const [, force] = useState(0);
const [tab, setTab] = useState("pending");
const [reviewing, setReviewing] = useState(null);
const list = window.NADI_ADMIN.PACK_PURCHASES;
const filtered = list.filter((p) => p.status === tab || (tab === "pending" && p.status === "pending"));
const tabs = [
{ id: "pending", label: "Pending", n: list.filter((p) => p.status === "pending").length },
{ id: "confirmed", label: "Confirmed", n: list.filter((p) => p.status === "confirmed").length },
{ id: "rejected", label: "Rejected", n: list.filter((p) => p.status === "rejected").length },
];
const update = async (purchase, status) => {
const token = localStorage.getItem("nadi_token");
if (!token) return;
try {
const action = status === "confirmed" ? "confirm" : "reject";
const res = await fetch(`/api/admin/payments/${purchase.id}/${action}`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` }
});
if (res.ok) {
if (window.NADI_RELOAD_ADMIN_DATA) {
await window.NADI_RELOAD_ADMIN_DATA();
} else {
purchase.status = status;
purchase.decidedAt = new Date().toISOString().replace("T", " ").slice(0, 16);
}
force((n) => n + 1);
setReviewing(null);
showToast && showToast(
status === "confirmed" ? `${purchase.packageName} confirmed for ${purchase.memberName.split(" ")[0]}.`
: status === "rejected" ? `Purchase rejected.` : `Updated.`
);
} else {
const err = await res.json();
showToast && showToast(err.detail || "Action failed.");
}
} catch (e) {
console.error(e);
showToast && showToast("Error connecting to server.");
}
};
return (
{tabs.map((tb) => (
))}
{filtered.length === 0 && (
no {tab} purchases
)}
{filtered.map((p) => {
const member = window.NADI_ADMIN.findMember(p.memberId);
const bank = (window.NADI_ADMIN.BANK_ACCOUNTS || []).find((b) => b.id === p.bankId);
return (
);
})}
setReviewing(null)} maxHeight="94%">
{reviewing && setReviewing(null)}
onConfirm={() => update(reviewing, "confirmed")}
onReject={() => update(reviewing, "rejected")}
openMember={(m) => { setReviewing(null); openMember && openMember(m); }}
/>}
);
}
function PackPurchaseReviewer({ purchase, onClose, onConfirm, onReject, openMember }) {
const member = window.NADI_ADMIN.findMember(purchase.memberId);
const bank = (window.NADI_ADMIN.BANK_ACCOUNTS || []).find((b) => b.id === purchase.bankId);
const isPending = purchase.status === "pending";
return (
purchase · #{purchase.id}
{purchase.packageName}
{purchase.classes} credit{purchase.classes === 1 ? "" : "s"} · {fmtIDR(purchase.amount)} · submitted {purchase.submitted}
{/* Member */}
{/* Bank */}
transferred to
{bank ? bank.bank : "—"}
{bank ? bank.number : "—"}
{purchase.note &&
"{purchase.note}"}
{/* Receipt */}
receipt
{purchase.proof ? (

) : (
<>
no-screenshot.jpg
>
)}
{fmtIDR(purchase.amount)} · {bank ? bank.bank : "—"}
verify the amount, account & timestamp match
{/* Actions */}
{isPending ? (
) : (
{purchase.status} {purchase.decidedAt ? `· ${purchase.decidedAt}` : ""}
)}
);
}
// ── Sub-header used by all secondary screens ─────────────────────
function SubHeader({ title, back, onAdd, addLabel }) {
return (
{onAdd && (
)}
);
}
Object.assign(window, {
AdminMoreScreen, AdminPackagesScreen, AdminClassTypesScreen, AdminStudiosScreen,
AdminAccountsScreen, AdminPaymentsScreen, AdminReportsScreen,
AdminBankAccountsScreen, AdminPackPurchasesScreen,
SubHeader,
});