const KEY_TX = "finance_tx_v2";
const KEY_ACCOUNTS = "finance_accounts_v1";
const el = (id) => document.getElementById(id);
const rupiah = (n) => new Intl.NumberFormat("id-ID").format(Number(n || 0));
function nowLocalDatetimeValue() {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function loadTx() { return JSON.parse(localStorage.getItem(KEY_TX) || "[]"); }
function saveTx(data) { localStorage.setItem(KEY_TX, JSON.stringify(data)); }
function loadAccounts() {
const raw = localStorage.getItem(KEY_ACCOUNTS);
if (raw) return JSON.parse(raw);
const defaults = [
{ id: crypto.randomUUID(), name: "Cash", type: "CASH", opening: 0 },
{ id: crypto.randomUUID(), name: "Bank", type: "BANK", opening: 0 },
{ id: crypto.randomUUID(), name: "E-Wallet", type: "EWALLET", opening: 0 },
];
localStorage.setItem(KEY_ACCOUNTS, JSON.stringify(defaults));
return defaults;
}
function saveAccounts(data) { localStorage.setItem(KEY_ACCOUNTS, JSON.stringify(data)); }
let filterState = { from: "", to: "", q: "" };
function refreshAccountSelects() {
const accounts = loadAccounts();
const accountSel = el("account");
const toAccountSel = el("toAccount");
accountSel.innerHTML = "";
toAccountSel.innerHTML = ``;
for (const a of accounts) {
const o1 = document.createElement("option");
o1.value = a.id; o1.textContent = `${a.name} (${a.type})`;
accountSel.appendChild(o1);
const o2 = document.createElement("option");
o2.value = a.id; o2.textContent = `${a.name} (${a.type})`;
toAccountSel.appendChild(o2);
}
}
function applyFilter(tx) {
const { from, to, q } = filterState;
let out = [...tx];
if (from) out = out.filter(x => new Date(x.occurredAt) >= new Date(from + "T00:00:00"));
if (to) out = out.filter(x => new Date(x.occurredAt) <= new Date(to + "T23:59:59"));
if (q) {
const qq = q.toLowerCase();
out = out.filter(x => ([x.note,x.category,x.payMethod,(x.tags||[]).join(",")].join(" ").toLowerCase()).includes(qq));
}
return out;
}
function computeBalances(allTx, accounts) {
const bal = Object.fromEntries(accounts.map(a => [a.id, a.opening || 0]));
for (const tx of allTx) {
if (tx.kind === "INCOME") bal[tx.accountId] = (bal[tx.accountId] || 0) + tx.amount;
if (tx.kind === "EXPENSE") bal[tx.accountId] = (bal[tx.accountId] || 0) - tx.amount;
if (tx.kind === "TRANSFER") {
bal[tx.accountId] = (bal[tx.accountId] || 0) - tx.amount;
if (tx.toAccountId) bal[tx.toAccountId] = (bal[tx.toAccountId] || 0) + tx.amount;
}
}
return bal;
}
function renderSummary(filteredTx) {
let totalIn = 0, totalOut = 0, totalTransfer = 0;
for (const tx of filteredTx) {
if (tx.kind === "INCOME") totalIn += tx.amount;
if (tx.kind === "EXPENSE") totalOut += tx.amount;
if (tx.kind === "TRANSFER") totalTransfer += tx.amount;
}
const accounts = loadAccounts();
const bal = computeBalances(loadTx(), accounts);
el("summary").innerHTML = `
Income: ${rupiah(totalIn)} | Expense: ${rupiah(totalOut)} | Net: ${rupiah(totalIn - totalOut)} | Transfer: ${rupiah(totalTransfer)}
Saldo per akun (semua data):
${accounts.map(a => `- ${a.name}: ${rupiah(bal[a.id] || 0)}
`).join("")}
`;
}
function render() {
refreshAccountSelects();
const allTx = loadTx();
const tx = applyFilter(allTx).sort((a,b) => new Date(b.occurredAt) - new Date(a.occurredAt));
const accounts = loadAccounts();
const accName = Object.fromEntries(accounts.map(a => [a.id, a.name]));
const tbody = el("list");
tbody.innerHTML = "";
for (const item of tx) {
const tr = document.createElement("tr");
tr.innerHTML = `
${new Date(item.occurredAt).toLocaleString("id-ID")} |
${item.kind} |
${accName[item.accountId] || "-"} |
${item.kind === "TRANSFER" ? (accName[item.toAccountId] || "-") : "-"} |
${item.category || "-"} |
${(item.tags || []).join(", ")} |
${rupiah(item.amount)} |
${item.payMethod || "-"} |
${item.note || ""} |
|
`;
tbody.appendChild(tr);
}
tbody.querySelectorAll("button[data-del]").forEach(btn => {
btn.onclick = () => {
const id = btn.getAttribute("data-del");
saveTx(loadTx().filter(x => x.id !== id));
render();
};
});
tbody.querySelectorAll("button[data-edit]").forEach(btn => {
btn.onclick = () => startEdit(btn.getAttribute("data-edit"));
});
renderSummary(tx);
}
function resetForm() {
el("editingId").value = "";
el("submitBtn").textContent = "Tambah";
el("cancelEdit").style.display = "none";
el("form").reset();
el("occurredAt").value = nowLocalDatetimeValue();
}
function startEdit(id) {
const tx = loadTx().find(x => x.id === id);
if (!tx) return;
el("editingId").value = tx.id;
el("submitBtn").textContent = "Simpan";
el("cancelEdit").style.display = "inline-block";
const d = new Date(tx.occurredAt);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
el("occurredAt").value = d.toISOString().slice(0, 16);
el("kind").value = tx.kind;
el("account").value = tx.accountId;
el("toAccount").value = tx.toAccountId || "";
el("category").value = tx.category || "";
el("amount").value = tx.amount;
el("tags").value = (tx.tags || []).join(", ");
el("payMethod").value = tx.payMethod || "";
el("note").value = tx.note || "";
}
el("form").addEventListener("submit", (e) => {
e.preventDefault();
const kind = el("kind").value;
const accountId = el("account").value;
const toAccountId = el("toAccount").value || null;
if (kind === "TRANSFER" && (!toAccountId || toAccountId === accountId)) {
alert("Transfer butuh akun tujuan yang berbeda.");
return;
}
const payload = {
id: el("editingId").value || crypto.randomUUID(),
occurredAt: new Date(el("occurredAt").value).toISOString(),
kind,
accountId,
toAccountId,
category: el("category").value.trim() || null,
amount: Number(el("amount").value),
tags: el("tags").value.split(",").map(s => s.trim()).filter(Boolean),
payMethod: el("payMethod").value.trim() || null,
note: el("note").value.trim() || null,
createdAt: new Date().toISOString(),
};
if (!payload.amount || payload.amount <= 0) {
alert("Nominal harus > 0");
return;
}
const data = loadTx();
const idx = data.findIndex(x => x.id === payload.id);
if (idx >= 0) data[idx] = payload;
else data.push(payload);
saveTx(data);
resetForm();
render();
});
el("cancelEdit").onclick = () => resetForm();
el("applyFilter").onclick = () => {
filterState = { from: el("from").value, to: el("to").value, q: el("q").value.trim() };
render();
};
el("resetAll").onclick = () => {
if (!confirm("Yakin reset semua data?")) return;
localStorage.removeItem(KEY_TX);
localStorage.removeItem(KEY_ACCOUNTS);
location.reload();
};
// Init
resetForm();
render();