commit aae84ec9b61d2452eb0c30677ba9862bbe0dcb0b Author: Codex Date: Fri Jun 19 11:41:39 2026 +0200 Initial kostenverdeler webapp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafff2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6810a92 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Kostenverdeler + +Een eenvoudige webapplicatie waarmee twee personen gezamenlijke kosten kunnen bijhouden en automatisch kunnen zien wie nog welk bedrag moet betalen om gelijk uit te komen. + +## Gebruik + +Open `index.html` in een browser. De app bewaart gegevens lokaal in de browser via `localStorage`. + +## Functionaliteit + +- namen van twee personen aanpassen +- uitgaven toevoegen met omschrijving, bedrag en betaler +- kosten verdelen over beide personen of over een specifieke persoon +- automatisch afrekenadvies +- uitgaven verwijderen +- CSV-export diff --git a/app.js b/app.js new file mode 100644 index 0000000..98315e3 --- /dev/null +++ b/app.js @@ -0,0 +1,271 @@ +const storageKey = "kostenverdeler:v1"; + +const defaultState = { + people: ["Persoon 1", "Persoon 2"], + expenses: [] +}; + +const els = { + personAName: document.querySelector("#personAName"), + personBName: document.querySelector("#personBName"), + personALabel: document.querySelector("#personALabel"), + personBLabel: document.querySelector("#personBLabel"), + shareALabel: document.querySelector("#shareALabel"), + shareBLabel: document.querySelector("#shareBLabel"), + paidBy: document.querySelector("#paidBy"), + form: document.querySelector("#expenseForm"), + description: document.querySelector("#description"), + amount: document.querySelector("#amount"), + shareA: document.querySelector("#shareA"), + shareB: document.querySelector("#shareB"), + totalSpent: document.querySelector("#totalSpent"), + paidA: document.querySelector("#paidA"), + paidB: document.querySelector("#paidB"), + settlementText: document.querySelector("#settlementText"), + balanceDetail: document.querySelector("#balanceDetail"), + emptyState: document.querySelector("#emptyState"), + expenseList: document.querySelector("#expenseList"), + resetButton: document.querySelector("#resetButton"), + exportButton: document.querySelector("#exportButton") +}; + +let state = loadState(); + +function loadState() { + try { + const saved = JSON.parse(localStorage.getItem(storageKey)); + if (!saved || !Array.isArray(saved.people) || !Array.isArray(saved.expenses)) { + return structuredClone(defaultState); + } + return saved; + } catch { + return structuredClone(defaultState); + } +} + +function saveState() { + localStorage.setItem(storageKey, JSON.stringify(state)); +} + +function money(value) { + return new Intl.NumberFormat("nl-NL", { + style: "currency", + currency: "EUR" + }).format(value); +} + +function cleanName(value, fallback) { + return value.trim() || fallback; +} + +function calculate() { + const totals = { + paid: [0, 0], + owes: [0, 0], + total: 0 + }; + + for (const expense of state.expenses) { + totals.total += expense.amount; + totals.paid[expense.paidBy] += expense.amount; + + const participants = expense.participants.length ? expense.participants : [0, 1]; + const share = expense.amount / participants.length; + for (const personIndex of participants) { + totals.owes[personIndex] += share; + } + } + + return totals; +} + +function renderPeople() { + const [a, b] = state.people; + els.personAName.value = a; + els.personBName.value = b; + els.personALabel.textContent = `${a} betaalde`; + els.personBLabel.textContent = `${b} betaalde`; + els.shareALabel.textContent = a; + els.shareBLabel.textContent = b; + + els.paidBy.innerHTML = ""; + state.people.forEach((name, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = name; + els.paidBy.append(option); + }); +} + +function renderSummary() { + const totals = calculate(); + els.totalSpent.textContent = money(totals.total); + els.paidA.textContent = money(totals.paid[0]); + els.paidB.textContent = money(totals.paid[1]); + + const balances = totals.paid.map((paid, index) => paid - totals.owes[index]); + const cents = Math.round(Math.abs(balances[0]) * 100); + + if (state.expenses.length === 0) { + els.settlementText.textContent = "Alles is in balans."; + els.balanceDetail.textContent = "Voeg een uitgave toe om de verdeling te berekenen."; + return; + } + + if (cents === 0) { + els.settlementText.textContent = "Alles is in balans."; + els.balanceDetail.textContent = "Beide personen dragen precies evenveel bij aan hun eigen aandeel."; + return; + } + + const receiver = balances[0] > 0 ? 0 : 1; + const payer = receiver === 0 ? 1 : 0; + const amount = Math.abs(balances[receiver]); + + els.settlementText.textContent = `${state.people[payer]} betaalt ${money(amount)} aan ${state.people[receiver]}.`; + els.balanceDetail.textContent = `${state.people[receiver]} heeft meer voorgeschoten dan diens aandeel. Na deze betaling staan jullie gelijk.`; +} + +function renderExpenses() { + els.expenseList.innerHTML = ""; + els.emptyState.hidden = state.expenses.length > 0; + + const fragment = document.createDocumentFragment(); + [...state.expenses].reverse().forEach((expense) => { + const item = document.createElement("li"); + item.className = "expense-item"; + + const details = document.createElement("div"); + const title = document.createElement("p"); + title.className = "expense-title"; + title.textContent = expense.description; + + const participants = expense.participants.map((index) => state.people[index]).join(" + "); + const meta = document.createElement("p"); + meta.className = "expense-meta"; + meta.textContent = `Betaald door ${state.people[expense.paidBy]} - verdeeld over ${participants}`; + + const amount = document.createElement("div"); + amount.className = "expense-amount"; + amount.textContent = money(expense.amount); + + const button = document.createElement("button"); + button.className = "delete-button"; + button.type = "button"; + button.title = "Verwijderen"; + button.setAttribute("aria-label", `${expense.description} verwijderen`); + button.textContent = "x"; + button.addEventListener("click", () => { + state.expenses = state.expenses.filter((itemExpense) => itemExpense.id !== expense.id); + saveState(); + render(); + }); + + details.append(title, meta); + item.append(details, amount, button); + fragment.append(item); + }); + + els.expenseList.append(fragment); +} + +function render() { + renderPeople(); + renderSummary(); + renderExpenses(); +} + +function updateNames() { + state.people = [ + cleanName(els.personAName.value, "Persoon 1"), + cleanName(els.personBName.value, "Persoon 2") + ]; + saveState(); + render(); +} + +function parseAmount(value) { + return Number(value.replace(",", ".")); +} + +function createId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +els.personAName.addEventListener("change", updateNames); +els.personBName.addEventListener("change", updateNames); + +els.form.addEventListener("submit", (event) => { + event.preventDefault(); + + const participants = []; + if (els.shareA.checked) participants.push(0); + if (els.shareB.checked) participants.push(1); + + if (participants.length === 0) { + els.form.classList.add("warn"); + setTimeout(() => els.form.classList.remove("warn"), 500); + return; + } + + const amount = parseAmount(els.amount.value); + if (!Number.isFinite(amount) || amount <= 0) return; + + const description = els.description.value.trim(); + if (!description) { + els.description.focus(); + return; + } + + state.expenses.push({ + id: createId(), + description, + amount: Math.round(amount * 100) / 100, + paidBy: Number(els.paidBy.value), + participants, + createdAt: new Date().toISOString() + }); + + saveState(); + els.form.reset(); + els.shareA.checked = true; + els.shareB.checked = true; + render(); + els.description.focus(); +}); + +els.resetButton.addEventListener("click", () => { + const confirmed = window.confirm("Weet je zeker dat je alle namen en uitgaven wilt wissen?"); + if (!confirmed) return; + state = structuredClone(defaultState); + saveState(); + render(); +}); + +els.exportButton.addEventListener("click", () => { + const header = ["Omschrijving", "Bedrag", "Betaald door", "Verdeeld over", "Datum"]; + const rows = state.expenses.map((expense) => [ + expense.description, + expense.amount.toFixed(2).replace(".", ","), + state.people[expense.paidBy], + expense.participants.map((index) => state.people[index]).join(" + "), + new Date(expense.createdAt).toLocaleString("nl-NL") + ]); + + const csv = [header, ...rows] + .map((row) => row.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(";")) + .join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "kostenverdeler.csv"; + link.click(); + URL.revokeObjectURL(url); +}); + +render(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..b8377b0 --- /dev/null +++ b/index.html @@ -0,0 +1,110 @@ + + + + + + Kostenverdeler + + + +
+
+
+

Gezamenlijke uitgaven

+

Kostenverdeler

+
+ +
+ +
+
+ Totaal uitgegeven + € 0,00 +
+
+ Persoon 1 betaalde + € 0,00 +
+
+ Persoon 2 betaalde + € 0,00 +
+
+ +
+
+

Af te rekenen

+

Alles is in balans.

+
+

Voeg een uitgave toe om de verdeling te berekenen.

+
+ +
+
+
+

Personen

+
+
+ + +
+
+ +
+
+

Uitgave toevoegen

+
+
+ + + +
+ Verdelen over +
+ + +
+
+ +
+
+ +
+
+

Uitgaven

+ +
+
+ Nog geen uitgaven. Begin met de eerste gedeelde kostenpost. +
+
    +
    +
    +
    + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..abd8e9a --- /dev/null +++ b/styles.css @@ -0,0 +1,384 @@ +:root { + color-scheme: light; + --ink: #18201f; + --muted: #66706c; + --line: #d9dfdc; + --paper: #fbfbf8; + --panel: #ffffff; + --accent: #166b5c; + --accent-dark: #0f4d42; + --accent-soft: #dff1eb; + --warn-soft: #fff0d6; + --shadow: 0 18px 50px rgba(24, 32, 31, 0.09); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(22, 107, 92, 0.08), transparent 35%), + linear-gradient(315deg, rgba(210, 79, 58, 0.08), transparent 32%), + var(--paper); + color: var(--ink); +} + +button, +input, +select { + font: inherit; +} + +.app-shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 42px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 0 22px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: clamp(2rem, 6vw, 4.4rem); + line-height: 0.95; +} + +h2 { + margin-bottom: 0; + font-size: 1.15rem; +} + +.icon-button, +.text-button, +.primary-button { + border: 0; + cursor: pointer; +} + +.icon-button { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 8px; +} + +.subtle { + background: var(--panel); + color: var(--muted); + box-shadow: inset 0 0 0 1px var(--line); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.summary-card, +.panel, +.balance-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow); +} + +.summary-card { + padding: 18px; +} + +.summary-label, +label span, +legend { + color: var(--muted); + font-size: 0.86rem; + font-weight: 700; +} + +.summary-card strong { + display: block; + margin-top: 8px; + font-size: 1.65rem; +} + +.balance-panel { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 22px; + margin: 14px 0; + padding: 22px; + background: linear-gradient(135deg, var(--accent-soft), #fff); +} + +.balance-panel h2 { + font-size: clamp(1.45rem, 3vw, 2.2rem); +} + +.balance-panel p:last-child { + max-width: 420px; + margin-bottom: 0; + color: var(--muted); + line-height: 1.45; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(280px, 0.85fr) minmax(420px, 1.15fr); + gap: 14px; + align-items: start; +} + +.panel { + padding: 18px; +} + +.setup-panel { + grid-column: 1; +} + +.form-panel { + grid-column: 1; +} + +.expenses-panel { + grid-column: 2; + grid-row: 1 / span 2; +} + +.section-heading { + margin-bottom: 16px; +} + +.row-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.name-grid, +.expense-form { + display: grid; + gap: 14px; +} + +.expense-form { + grid-template-columns: 1fr 1fr; +} + +.full-row { + grid-column: 1 / -1; +} + +label { + display: grid; + gap: 7px; +} + +input, +select { + width: 100%; + min-height: 44px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--ink); + padding: 10px 12px; + outline: none; +} + +input:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(22, 107, 92, 0.14); +} + +fieldset { + margin: 0; + padding: 0; + border: 0; +} + +legend { + margin-bottom: 8px; +} + +.split-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.split-options label { + display: flex; + min-height: 44px; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px 12px; + background: #fff; +} + +.split-options input { + width: 18px; + min-height: 18px; + accent-color: var(--accent); +} + +.primary-button { + min-height: 48px; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 800; +} + +.primary-button:hover { + background: var(--accent-dark); +} + +.text-button { + min-height: 36px; + border-radius: 8px; + background: var(--accent-soft); + color: var(--accent-dark); + padding: 0 12px; + font-weight: 800; +} + +.empty-state { + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + padding: 20px; + text-align: center; +} + +.expense-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.expense-item { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 14px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + padding: 13px; + background: #fff; +} + +.expense-title { + margin-bottom: 4px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.expense-meta { + margin-bottom: 0; + color: var(--muted); + font-size: 0.88rem; +} + +.expense-amount { + font-weight: 900; + white-space: nowrap; +} + +.delete-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 0; + border-radius: 8px; + background: #f7ded8; + color: #8b2f20; + cursor: pointer; +} + +.warn { + background: var(--warn-soft); +} + +@media (max-width: 820px) { + .summary-grid, + .content-grid, + .expense-form, + .split-options { + grid-template-columns: 1fr; + } + + .expenses-panel, + .setup-panel, + .form-panel { + grid-column: auto; + grid-row: auto; + } + + .balance-panel { + display: block; + } + + .balance-panel p:last-child { + margin-top: 12px; + } +} + +@media (max-width: 520px) { + .app-shell { + width: min(100% - 20px, 1120px); + padding-top: 14px; + } + + .topbar { + align-items: flex-start; + } + + .summary-card, + .panel, + .balance-panel { + padding: 14px; + } + + .expense-item { + grid-template-columns: 1fr auto; + } + + .expense-amount { + grid-column: 1; + } +}