/* Tables, filters, charts and main screens for Kisongi Farm Tracker. */
const PALETTE = ["#2E7D32", "#FBC02D", "#558B2F", "#8D6E63", "#0288D1", "#7B1FA2", "#C2185B", "#F57C00", "#00897B", "#5D4037", "#9E9D24"];
/* ---------------- Filter bar ---------------- */
function FilterBar({ kind, filters, setFilters, options, onReset }) {
const isExpense = kind === "expense";
return (
);
}
/* ---------------- Records table ---------------- */
function RecordsTable({ kind, rows, role, onEdit, onDelete, onView, loading }) {
const isExpense = kind === "expense";
const labelCol = isExpense ? "Category" : "Source";
const colKey = isExpense ? "category" : "source";
const sorted = useMemo(() => [...rows].sort((a,b) => {
const ad = a.date || "";
const bd = b.date || "";
return ad.localeCompare(bd) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
}), [rows]);
let running = 0;
const withRunning = sorted.map(r => { running += (r.amount || 0); return { ...r, _running: running }; });
const total = running;
const [swipeId, setSwipeId] = useState(null);
const swipeStart = useRef({ x: 0, id: null });
const onTouchStart = (e, id) => {
if (role !== "admin") return;
swipeStart.current = { x: e.touches[0].clientX, id };
};
const onTouchMove = (e, id) => {
if (role !== "admin" || swipeStart.current.id !== id) return;
const dx = e.touches[0].clientX - swipeStart.current.x;
if (dx < -40) setSwipeId(id);
else if (dx > 10) setSwipeId(null);
};
return (
#
Date
{labelCol}
Description
Amount
Running
Notes
{loading && Array.from({length: 6}).map((_, i) => )}
{!loading && withRunning.map((r, i) => (
{i + 1}
{formatDate(r.date)}
{r[colKey]}
{r.description}
{formatUGX(r.amount, { bare: true })}
{formatUGX(r._running, { bare: true })}
{r.notes || "—"}
{role === "admin" ? (
<>
onEdit(r)} className="h-7 w-7 inline-flex items-center justify-center rounded text-stone-500 hover:bg-stone-100 hover:text-[#2E7D32]" title="Edit">
onDelete(r)} className="h-7 w-7 inline-flex items-center justify-center rounded text-stone-500 hover:bg-rose-50 hover:text-rose-600" title="Delete">
>
) : (
onView(r)} className="h-7 w-7 inline-flex items-center justify-center rounded text-stone-500 hover:bg-stone-100 hover:text-[#2E7D32]" title="View">
)}
))}
{!loading && rows.length > 0 && (
Total
{formatUGX(total, { bare: true })}
)}
{loading && Array.from({length: 5}).map((_, i) => (
))}
{!loading && withRunning.map((r, i) => (
{role === "admin" && (
onDelete(r)} className="absolute right-0 top-0 bottom-0 w-20 bg-rose-600 text-white flex items-center justify-center gap-1 text-sm font-medium">
Delete
)}
onTouchStart(e, r.id)}
onTouchMove={(e) => onTouchMove(e, r.id)}
onTouchEnd={() => { if (swipeId !== r.id) setSwipeId(null); }}
onClick={() => role === "admin" ? onEdit(r) : onView(r)}
>
{r[colKey]}
{r.description}
{formatDate(r.date)}{r.notes && ` · ${r.notes}`}
{formatUGX(r.amount, { bare: true })}
))}
{!loading && rows.length > 0 && (
Total
{formatUGX(total, { bare: true })}
)}
);
}
function hashIdx(s) {
let h = 0;
for (let i = 0; i < (s || "").length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h) % PALETTE.length;
}
/* ---------------- Empty states ---------------- */
function EmptyState({ kind, filtered, role, onAdd, onReset }) {
if (filtered) {
return (
No {kind} match your filters.
Reset filters
);
}
return (
No {kind} yet.
{role === "admin"
? `Tap "Add ${kind === "expenses" ? "expense" : "income"}" to log your first one.`
: "Ask the admin to add the first entry."}
{role === "admin" && (
Add {kind === "expenses" ? "expense" : "income"}
)}
);
}
/* ---------------- Add / Edit form (controlled by parent via formRef) ---------------- */
function RecordForm({ kind, initial, formRef, onDeleteRequest }) {
const isExpense = kind === "expense";
const [date, setDate] = useState(initial?.date || todayISO());
const [amount, setAmount] = useState(initial?.amount?.toString() || "");
const [description, setDescription] = useState(initial?.description || "");
const [category, setCategory] = useState(initial?.[isExpense ? "category" : "source"] || "");
const [notes, setNotes] = useState(initial?.notes || "");
const options = isExpense ? window.CATEGORIES : window.SOURCES;
// Expose collect() to parent via ref
useEffect(() => {
if (!formRef) return;
formRef.current = () => {
if (!date || !description || !category || amount === "") return null;
return {
...(initial || {}),
id: initial?.id || ("n" + Math.random().toString(36).slice(2, 8)),
date,
amount: parseFloat(amount) || 0,
description,
[isExpense ? "category" : "source"]: category,
notes,
};
};
}, [date, amount, description, category, notes, initial, isExpense, formRef]);
const suggestion = useMemo(() => {
if (!isExpense || !description || category) return null;
const d = description.toLowerCase();
const map = [
["transport", "Transport"], ["taxi", "Transport"], ["fuel", "Transport"],
["lunch", "Meals"], ["meal", "Meals"], ["breakfast", "Meals"], ["food", "Meals"],
["seedling", "Seedlings"], ["sucker", "Seedlings"],
["hoe", "Tools"], ["panga", "Tools"], ["tool", "Tools"],
["dig", "Land Preparation"], ["tractor", "Land Preparation"], ["land", "Land Preparation"],
["chairman", "Community/Admin"], ["lc", "Community/Admin"],
["agronomist", "Labor"], ["labor", "Labor"], ["worker", "Labor"],
["padlock", "Supplies"], ["supplies", "Supplies"], ["string", "Supplies"], ["measure", "Supplies"],
["boot", "PPE/Equipment"], ["glove", "PPE/Equipment"],
["goodwill", "Goodwill"],
];
for (const [k, v] of map) if (d.includes(k)) return v;
return null;
}, [description, category, isExpense]);
return (
);
}
/* ---------------- Details viewer ---------------- */
function RecordDetails({ kind, record }) {
const isExpense = kind === "expense";
const colKey = isExpense ? "category" : "source";
return (
Amount
{formatUGX(record.amount, { bare: true })} UGX
{record[colKey]}
}/>
);
}
function Field({ label, value }) {
return (
);
}
Object.assign(window, { FilterBar, RecordsTable, EmptyState, RecordForm, RecordDetails, PALETTE, hashIdx });