/* Main App — coordinates state, role, routing */ function App() { // Auth state from localStorage const [signedIn, setSignedIn] = useState(window.API.isLoggedIn()); const [username, setUsername] = useState(window.API.getUsername()); const [role, setRole] = useState(window.API.getRole()); // Hash-based simple routing for tabs const parseHash = () => { const h = window.location.hash.replace(/^#\/?/, ""); const [path, qs] = h.split("?"); const params = new URLSearchParams(qs || ""); return { tab: path || "expenses", params }; }; const [route, setRoute] = useState(parseHash()); useEffect(() => { const onHash = () => setRoute(parseHash()); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); const setTab = (tab) => { const { params } = parseHash(); const qs = params.toString(); window.location.hash = `#/${tab}${qs ? `?${qs}` : ""}`; }; const setQuery = (key, val) => { const { tab, params } = parseHash(); if (val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0)) { params.delete(key); } else { params.set(key, Array.isArray(val) ? val.join("|") : val); } const qs = params.toString(); window.location.hash = `#/${tab}${qs ? `?${qs}` : ""}`; }; // Data — loaded from API const [expenses, setExpenses] = useState([]); const [income, setIncome] = useState([]); const [loading, setLoading] = useState(true); const toast = useToast(); // Load data on mount and when filters change const filters = useMemo(() => ({ from: route.params.get("from") || "", to: route.params.get("to") || "", cats: (route.params.get("cats") || "").split("|").filter(Boolean), q: route.params.get("q") || "", }), [route]); const loadData = async () => { if (!signedIn) return; try { setLoading(true); const [exp, inc] = await Promise.all([ window.API.getExpenses(filters), window.API.getIncome(filters), ]); setExpenses((exp || []).map(r => ({ ...r, date: r.entry_date }))); setIncome((inc || []).map(r => ({ ...r, date: r.entry_date }))); } catch (e) { toast({ tone: "error", message: e.message }); } finally { setLoading(false); } }; const filterKey = [filters.from, filters.to, filters.cats.join("|"), filters.q].join("~"); useEffect(() => { loadData(); }, [signedIn, filterKey]); const setFilters = (next) => { setQuery("from", next.from); setQuery("to", next.to); setQuery("cats", next.cats); setQuery("q", next.q); }; const resetFilters = () => setFilters({ from: "", to: "", cats: [], q: "" }); // Apply client-side filters for rows without dates const applyFilters = (rows, key) => rows.filter(r => { if (filters.from || filters.to) { if (!r.date) return false; if (filters.from && r.date < filters.from) return false; if (filters.to && r.date > filters.to) return false; } if (filters.cats.length && !filters.cats.includes(r[key])) return false; if (filters.q) { const q = filters.q.toLowerCase(); if (!(r.description || "").toLowerCase().includes(q) && !(r.notes || "").toLowerCase().includes(q)) return false; } return true; }); // Summary date filter (separate) const [summaryRange, setSummaryRange] = useState({}); // Drawer state const [drawer, setDrawer] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); // CRUD — calls API then refreshes const upsert = async (kind, record) => { try { if (kind === "expense") { if (record.id) { await window.API.updateExpense(record.id, record); } else { await window.API.createExpense(record); } } else { if (record.id) { await window.API.updateIncome(record.id, record); } else { await window.API.createIncome(record); } } toast({ tone: "success", message: "Saved" }); setDrawer(null); await loadData(); } catch (e) { toast({ tone: "error", message: e.message }); } }; const remove = async (kind, id) => { try { if (kind === "expense") { await window.API.deleteExpense(id); } else { await window.API.deleteIncome(id); } toast({ tone: "success", message: "Deleted" }); setConfirmDelete(null); setDrawer(null); await loadData(); } catch (e) { toast({ tone: "error", message: e.message }); } }; // Login handler const handleLogin = async (user, pass) => { try { const data = await window.API.login(user, pass); setSignedIn(true); setUsername(data.username); setRole(data.role); } catch (e) { throw e; } }; if (!signedIn) return ; const tab = route.tab; const filteredExp = applyFilters(expenses, "category"); const filteredInc = applyFilters(income, "source"); const expenseFiltered = filters.from || filters.to || filters.cats.length || filters.q; const headerForTab = { expenses: { title: "Expenses", subtitle: "Every shilling out of the farm" }, income: { title: "Income", subtitle: "Sales and other inflows" }, summary: { title: "Summary", subtitle: "Where the farm stands today" }, readme: { title: "Guide", subtitle: "How to keep the books" }, }; const head = headerForTab[tab]; return (
window.API.logout()}/>
{head && tab !== "readme" && (

{head.title}

{head.subtitle}

{role === "admin" && (tab === "expenses" || tab === "income") && ( )}
)} {tab === "expenses" && (
{loading ? (
Loading...
) : filteredExp.length === 0 ? ( setDrawer({ mode: "add", kind: "expense" })} onReset={resetFilters}/> ) : ( setDrawer({ mode: "edit", kind: "expense", record: r })} onDelete={(r) => setConfirmDelete({ kind: "expense", record: r })} onView={(r) => setDrawer({ mode: "view", kind: "expense", record: r })} /> )}
)} {tab === "income" && (
{loading ? (
Loading...
) : filteredInc.length === 0 ? ( setDrawer({ mode: "add", kind: "income" })} onReset={resetFilters}/> ) : ( setDrawer({ mode: "edit", kind: "income", record: r })} onDelete={(r) => setConfirmDelete({ kind: "income", record: r })} onView={(r) => setDrawer({ mode: "view", kind: "income", record: r })} /> )}
)} {tab === "summary" && ( )} {tab === "readme" && }
{/* FAB on mobile for admin */} {role === "admin" && (tab === "expenses" || tab === "income") && ( )} {/* Slide-over drawer */} upsert(drawer.kind, r)} onDeleteRequest={(r) => setConfirmDelete({ kind: drawer.kind, record: r })} toast={toast}/> {/* Delete confirm */} {confirmDelete && (
setConfirmDelete(null)}/>

Delete this {confirmDelete.kind}?

"{confirmDelete.record.description}" — {formatUGX(confirmDelete.record.amount)}. This can't be undone.

)}
); } /* DrawerHost — owns formRef so the footer Save button can collect form data */ function DrawerHost({ drawer, setDrawer, onSave, onDeleteRequest, toast }) { const formRef = useRef(null); const handleSave = () => { const data = formRef.current && formRef.current(); if (!data) { toast({ tone: "error", message: "Error: please fill in all required fields" }); return; } onSave(data); }; return ( setDrawer(null)} title={ drawer?.mode === "add" ? `Add ${drawer.kind}` : drawer?.mode === "edit" ? `Edit ${drawer.kind}` : drawer?.mode === "view" ? `${drawer?.kind === "expense" ? "Expense" : "Income"} details` : "" } footer={drawer && drawer.mode !== "view" ? (
) : null} > {drawer?.mode === "view" && drawer.record && } {drawer && drawer.mode !== "view" && ( )}
); } /* Render */ const root = ReactDOM.createRoot(document.getElementById("root")); root.render();