// 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 (
more

The library.

{/* Signed-in admin card */}
{admin.name}
{admin.role.toLowerCase()} · {admin.email}
{/* Main library sections */}
openSection("packages")} /> openSection("classes")} /> openSection("studios")} />
p.status === "pending").length} awaiting confirmation`} onClick={() => openSection("purchases")} /> b.active).length} active · ${window.NADI_ADMIN.BANK_ACCOUNTS.length} total`} onClick={() => openSection("banks")} /> openSection("payments")} /> openSection("reports")} />
openSection("accounts")} />
); } function Section({ title, children }) { return (
{title}
{children}
); } function MoreRow({ icon, label, sub, onClick }) { return ( ); } function MoreIcon({ id }) { const p = { width: 20, height: 20, viewBox: "0 0 22 22", fill: "none", stroke: "var(--primary)", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }; if (id === "packages") return ; if (id === "classes") return ; if (id === "studios") return ; if (id === "payments") return ; if (id === "reports") return ; if (id === "accounts") return ; if (id === "purchases") return ; if (id === "banks") return ; return null; } // ── PACKAGES CRUD ───────────────────────────────────────────────── function AdminPackagesScreen({ onBack, showToast }) { const [editing, setEditing] = useState(null); // package or "new" return (
setEditing("new")} addLabel="+ new pack" />
{window.NADI.PACKAGES.map((p) => ( ))}
setEditing(null)} maxHeight="90%"> setEditing(null)} showToast={showToast} />
); } function PackageEditor({ pkg, onClose, showToast }) { const isNew = !pkg; const [name, setName] = useState(pkg?.name || ""); const [classes, setClasses] = useState(pkg?.classes || 5); const [price, setPrice] = useState(pkg?.price || 750000); const [validity, setValidity] = useState(pkg?.validityDays || 30); const [badge, setBadge] = useState(pkg?.badge || ""); 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 (
setEditing("new")} addLabel="+ new class" />
{window.NADI.CLASS_TYPES.map((c) => { const accent = c.color === "primary" ? "var(--primary)" : c.color === "accent" ? "var(--accent)" : "var(--muted)"; return ( ); })}
setEditing(null)} maxHeight="90%"> setEditing(null)} showToast={showToast} />
); } function ClassEditor({ cls, onClose, showToast }) { const isNew = !cls; const [name, setName] = useState(cls?.name || ""); const [tagline, setTagline] = useState(cls?.tagline || ""); const [duration, setDuration] = useState(cls?.duration || 60); const [level, setLevel] = useState(cls?.level || "All levels"); const [color, setColor] = useState(cls?.color || "primary"); 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 (
showToast && showToast("Add studio dialog.")} addLabel="+ new room" />
{window.NADI.STUDIOS.map((s) => (
studio {s.id}

{s.full}

{s.desc}.

))}
); } // ── ACCOUNTS / ROLES ───────────────────────────────────────────── function AdminAccountsScreen({ onBack, showToast }) { const { useState, useEffect } = React; const [tab, setTab] = useState("accounts"); const [accounts, setAccounts] = useState(window.NADI_ADMIN.ACCOUNTS || []); const [loading, setLoading] = useState(true); const [editMode, setEditMode] = useState(false); const [editingAccount, setEditingAccount] = useState(null); const [promoteOpen, setPromoteOpen] = useState(false); const fetchAccounts = async () => { const token = localStorage.getItem("nadi_token"); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; try { const res = await fetch("/api/admin/accounts", { headers }); if (res.ok) { const data = await res.json(); setAccounts(data); // Sync with global namespace window.NADI_ADMIN.ACCOUNTS = data.map(a => { const names = (a.name || "Staff").split(" "); const initials = names.map(n => n[0]).slice(0, 2).join("").toUpperCase(); const staffRoles = a.roles ? a.roles.filter(r => r !== "Member") : [a.role || "Member"]; return { id: a.id, name: a.name, email: a.email, role: staffRoles.length > 0 ? staffRoles.join(", ") : "Member", roles: a.roles || [a.role || "Member"], status: a.status || "active", lastActive: "Just now", initials: initials, tone: "#C28464" }; }); } } catch (e) { console.error("Failed to fetch accounts", e); } finally { setLoading(false); } }; useEffect(() => { fetchAccounts(); }, []); return (
setEditMode(!editMode)} addLabel={editMode ? "Done" : "Edit"} />
{[["accounts","Accounts"],["roles","Roles"]].map(([id, label]) => ( ))}
{tab === "accounts" ? (
{accounts.filter(a => a.roles ? a.roles.some(r => r !== "Member") : a.role !== "Member").map((a) => { const names = (a.name || "Staff").split(" "); const initials = names.map(n => n[0]).slice(0, 2).join("").toUpperCase(); return ( ); })}
) : (
{window.NADI_ADMIN.ROLES.map((r) => (

{r.name}

{r.scope.toLowerCase()}
{r.perms.map((p) => ( {p.toLowerCase()} ))}
))}
)}
setEditingAccount(null)} maxHeight="90%"> {editingAccount && ( setEditingAccount(null)} onSave={fetchAccounts} showToast={showToast} /> )} setPromoteOpen(false)} maxHeight="90%"> setPromoteOpen(false)} onPromote={(member) => { setPromoteOpen(false); setEditingAccount(member); }} />
); } function PromoteMemberSheet({ accounts, onClose, onPromote }) { const { useState } = React; const [query, setQuery] = useState(""); const membersOnly = accounts.filter(a => a.roles ? !a.roles.some(r => r !== "Member") : a.role === "Member" ); const filtered = query ? membersOnly.filter(m => (m.name || "").toLowerCase().includes(query.toLowerCase()) || (m.email || "").toLowerCase().includes(query.toLowerCase()) ) : membersOnly; return (

Promote Member

setQuery(e.target.value)} placeholder="Search registered members..." style={{ width: "100%", padding: "12px 14px 12px 36px", border: "1px solid var(--rule)", background: "var(--bg)", fontFamily: "Geist, sans-serif", fontSize: 14, color: "var(--ink)", }} />
{filtered.map((user) => { const names = (user.name || "Member").split(" "); const initials = names.map(n => n[0]).slice(0, 2).join("").toUpperCase(); return ( ); })} {filtered.length === 0 && ( No registered members found{query ? ` matching "${query}"` : ""}. )}
); } function StaffEditor({ account, onClose, onSave, showToast }) { const { useState } = React; const [roles, setRoles] = useState(account.roles || [account.role || "Member"]); const [status, setStatus] = useState(account.status || "active"); const [saving, setSaving] = useState(false); const handleSave = async () => { setSaving(true); const token = localStorage.getItem("nadi_token"); if (token) { try { const res = await fetch(`/api/admin/accounts/${account.id}`, { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify({ roles, status }) }); if (res.ok) { showToast && showToast("Staff account updated."); onSave && onSave(); onClose(); } else { const err = await res.json(); showToast && showToast(err.detail || "Failed to update account."); } } catch (e) { console.error(e); showToast && showToast("Error updating account."); } finally { setSaving(false); } } else { // Fallback for mock mode (e.g. Nadi revamp) account.roles = roles; account.role = roles[0]; account.status = status; showToast && showToast("Staff account updated (mock)."); onSave && onSave(); onClose(); } }; const toggleRole = (r) => { if (roles.includes(r)) { if (roles.length > 1) { setRoles(roles.filter(x => x !== r)); } else { showToast && showToast("At least one role is required."); } } else { setRoles([...roles, r]); } }; return (
edit staff

{account.name}

{account.email}
Assign Roles
{["Owner", "Manager", "Front Desk", "Coach"].map((r) => { const active = roles.includes(r); return ( ); })}
Status
{["active", "inactive"].map((st) => { const active = status === st; return ( ); })}
); } // ── PAYMENTS ───────────────────────────────────────────────────── function AdminPaymentsScreen({ onBack, openMember }) { const total = window.NADI_ADMIN.PAYMENTS.reduce((s, p) => s + p.amount, 0); // group by date const byDate = {}; window.NADI_ADMIN.PAYMENTS.forEach((p) => { (byDate[p.date] = byDate[p.date] || []).push(p); }); const dates = Object.keys(byDate).sort().reverse(); return (
past 7 days
{fmtIDR(total)}
{window.NADI_ADMIN.PAYMENTS.length} payments
{dates.map((d) => { const dayTotal = byDate[d].reduce((s, p) => s + p.amount, 0); return (
{new Date(d).toLocaleDateString("en-GB", { weekday: "short", day: "2-digit", month: "short" }).toLowerCase()} {fmtIDR(dayTotal)}
{byDate[d].map((p) => { const m = window.NADI_ADMIN.findMember(p.memberId); return ( ); })}
); })}
); } // ── REPORTS (payouts, churn, fill rate) ────────────────────────── function AdminReportsScreen({ onBack, openCoach, openMember }) { const payments = window.NADI_ADMIN.PAYMENTS || []; const weekRevenue = payments.reduce((s, p) => s + p.amount, 0); const sessions = window.NADI.SCHEDULE || []; const totalBooked = sessions.reduce((s, x) => s + (x.booked || 0), 0); const totalCapacity = sessions.reduce((s, x) => s + (x.capacity || 14), 0); const fillRate = totalCapacity > 0 ? Math.round((totalBooked / totalCapacity) * 100) : 78; const members = window.NADI_ADMIN.MEMBERS || []; const newMembersCount = members.filter((m) => m.status === "new" || m.status === "New").length || 2; return (
{/* Weekly */}
this week
{/* 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 ? ( Receipt 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 (

{title}

{onAdd && ( )}
); } Object.assign(window, { AdminMoreScreen, AdminPackagesScreen, AdminClassTypesScreen, AdminStudiosScreen, AdminAccountsScreen, AdminPaymentsScreen, AdminReportsScreen, AdminBankAccountsScreen, AdminPackPurchasesScreen, SubHeader, });