feat: Phase 3 Schritte 16–18 — Pinnwand, Kontakte, Budget-Tracker

Pinnwand (Notes):
- server/routes/notes.js: GET (sortiert: angeheftet zuerst), POST, PUT, PATCH /pin, DELETE
- public/pages/notes.js: Masonry-Grid, Markdown-Light-Renderer (fett/kursiv/Liste),
  Farb-Auswahl (8 Farben), helle/dunkle Textfarbe je nach Hintergrund, Pin-Toggle
- public/styles/notes.css: Masonry-Layout, Sticky-Note-Karten, Hover-Aktionen

Kontakte:
- server/routes/contacts.js: GET (Kategorie- + Volltextfilter), POST, PUT, DELETE, GET /meta
- public/pages/contacts.js: Kategorie-Filter-Chips, Echtzeit-Suche, Gruppenansicht,
  tel:/mailto:/maps-Links, CRUD-Modal
- public/styles/contacts.css: Toolbar mit Suche, Filter-Chips, Kontaktliste, Aktions-Buttons

Budget-Tracker:
- server/routes/budget.js: GET (Monatfilter), GET /summary (Einnahmen/Ausgaben/Saldo +
  Aufschlüsselung), GET /export (CSV mit BOM), POST, PUT, DELETE, GET /meta
- public/pages/budget.js: Monatsnavigation, 3 Zusammenfassungs-Karten, Kategorie-Balken
  (reines CSS, kein Canvas), Transaktionsliste, Einnahme/Ausgabe-Toggle, CSV-Download
- public/styles/budget.css: Summary-Cards, Balkendiagramm, Transaktionsliste, Modal

Tests: 34 neue Tests (10 Notes + 9 Contacts + 15 Budget), gesamt 146/146

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 21:24:08 +01:00
parent 43e7ed55a9
commit 74b6e5f078
12 changed files with 2935 additions and 54 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"test:shopping": "node --experimental-sqlite test-shopping.js",
"test:meals": "node --experimental-sqlite test-meals.js",
"test:calendar": "node --experimental-sqlite test-calendar.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js"
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
+3
View File
@@ -21,6 +21,9 @@
<link rel="stylesheet" href="/styles/shopping.css" />
<link rel="stylesheet" href="/styles/meals.css" />
<link rel="stylesheet" href="/styles/calendar.css" />
<link rel="stylesheet" href="/styles/notes.css" />
<link rel="stylesheet" href="/styles/contacts.css" />
<link rel="stylesheet" href="/styles/budget.css" />
<!-- Lucide Icons (CDN) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
+425 -13
View File
@@ -1,25 +1,437 @@
/**
* Modul: Budget
* Zweck: Seite für das Budget-Modul
* Abhängigkeiten: /api.js
* Modul: Budget-Tracker (Budget)
* Zweck: Monatsübersicht, Kategorie-Balkendiagramm (Canvas), Transaktionsliste,
* CRUD, CSV-Export
* Abhängigkeiten: /api.js, /router.js (window.oikos)
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = {
month: '', // YYYY-MM
entries: [],
summary: null,
};
// --------------------------------------------------------
// Formatierung
// --------------------------------------------------------
function formatAmount(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
}
function formatMonthLabel(ym) {
const [y, m] = ym.split('-');
return `${MONTH_NAMES[parseInt(m, 10) - 1]} ${y}`;
}
function addMonths(ym, n) {
const [y, m] = ym.split('-').map(Number);
const d = new Date(y, m - 1 + n, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
// --------------------------------------------------------
// API
// --------------------------------------------------------
async function loadMonth(month) {
const [entriesRes, summaryRes] = await Promise.all([
api.get(`/budget?month=${month}`),
api.get(`/budget/summary?month=${month}`),
]);
state.month = month;
state.entries = entriesRes.data;
state.summary = summaryRes.data;
}
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
export async function render(container, { user }) {
const today = new Date();
state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Budget</h1>
<div class="budget-page">
<div class="budget-nav">
<button class="btn btn--icon" id="budget-prev" aria-label="Vorheriger Monat">
<i data-lucide="chevron-left"></i>
</button>
<button class="budget-nav__today" id="budget-today">Aktuell</button>
<span class="budget-nav__label" id="budget-label"></span>
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="Eintrag hinzufügen">
<i data-lucide="plus"></i>
</button>
<button class="btn btn--icon" id="budget-next" aria-label="Nächster Monat">
<i data-lucide="chevron-right"></i>
</button>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
<div id="budget-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="padding:2rem;text-align:center;color:var(--color-text-disabled);">Lade…</div>
</div>
</div>
`;
if (window.lucide) lucide.createIcons();
await loadMonth(state.month);
renderBody();
wireNav();
}
// --------------------------------------------------------
// Navigation
// --------------------------------------------------------
function wireNav() {
document.getElementById('budget-prev').addEventListener('click', async () => {
await loadMonth(addMonths(state.month, -1));
renderBody();
updateLabel();
});
document.getElementById('budget-next').addEventListener('click', async () => {
await loadMonth(addMonths(state.month, 1));
renderBody();
updateLabel();
});
document.getElementById('budget-today').addEventListener('click', async () => {
const today = new Date();
const m = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
if (m === state.month) return;
await loadMonth(m);
renderBody();
updateLabel();
});
document.getElementById('budget-add').addEventListener('click', () => openModal({ mode: 'create' }));
updateLabel();
}
function updateLabel() {
const lbl = document.getElementById('budget-label');
if (lbl) lbl.textContent = formatMonthLabel(state.month);
}
// --------------------------------------------------------
// Body
// --------------------------------------------------------
function renderBody() {
const body = document.getElementById('budget-body');
if (!body) return;
updateLabel();
const s = state.summary;
const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative';
body.innerHTML = `
<!-- Zusammenfassung -->
<div class="budget-summary">
<div class="budget-summary-card budget-summary-card--income">
<div class="budget-summary-card__label">Einnahmen</div>
<div class="budget-summary-card__amount">${formatAmount(s.income)}</div>
</div>
<div class="budget-summary-card budget-summary-card--expenses">
<div class="budget-summary-card__label">Ausgaben</div>
<div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div>
</div>
<div class="budget-summary-card ${balanceClass}">
<div class="budget-summary-card__label">Saldo</div>
<div class="budget-summary-card__amount">${formatAmount(s.balance)}</div>
</div>
</div>
<!-- Kategorie-Balken -->
${s.byCategory.length ? `
<div class="budget-chart-section">
<div class="budget-chart-section__title">Nach Kategorie</div>
<div class="budget-chart">
${renderCategoryBars(s.byCategory)}
</div>
</div>` : ''}
<!-- Transaktionsliste -->
<div class="budget-list-section">
<div class="budget-list-header">
<span class="budget-list-header__title">Transaktionen</span>
${state.entries.length ? `
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
<i data-lucide="download" style="width:14px;height:14px;margin-right:4px;"></i>CSV
</a>` : ''}
</div>
<div class="budget-list" id="budget-list">
${renderEntries()}
</div>
</div>
`;
if (window.lucide) lucide.createIcons();
document.getElementById('budget-list')?.addEventListener('click', async (e) => {
const delBtn = e.target.closest('[data-action="delete"]');
if (delBtn) { await deleteEntry(parseInt(delBtn.dataset.id, 10)); return; }
const item = e.target.closest('.budget-entry[data-id]');
if (item && !e.target.closest('[data-action]')) {
const entry = state.entries.find((e) => e.id === parseInt(item.dataset.id, 10));
if (entry) openModal({ mode: 'edit', entry });
}
});
}
function renderCategoryBars(byCategory) {
const maxAbs = Math.max(...byCategory.map((c) => Math.abs(c.total)), 1);
return byCategory.map((c) => {
const isExpense = c.total < 0;
const pct = Math.round((Math.abs(c.total) / maxAbs) * 100);
const cls = isExpense ? 'budget-bar-row__fill--expenses' : 'budget-bar-row__fill--income';
return `
<div class="budget-bar-row">
<div class="budget-bar-row__label" title="${escHtml(c.category)}">${escHtml(c.category)}</div>
<div class="budget-bar-row__track">
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
</div>
<div class="budget-bar-row__amount" style="color:${isExpense ? 'var(--color-danger)' : 'var(--color-success)'};">
${formatAmount(c.total)}
</div>
</div>
`;
}).join('');
}
function renderEntries() {
if (!state.entries.length) {
return `<div class="budget-empty">
<i data-lucide="receipt" style="width:48px;height:48px;color:var(--color-text-disabled);margin-bottom:var(--space-3);"></i>
<div style="font-size:var(--text-base);font-weight:600;">Keine Einträge</div>
<div style="font-size:var(--text-sm);margin-top:var(--space-1);">Noch keine Transaktionen für diesen Monat.</div>
</div>`;
}
return state.entries.map((e) => {
const isIncome = e.amount > 0;
const amtClass = isIncome ? 'budget-entry__amount--income' : 'budget-entry__amount--expenses';
const indClass = isIncome ? 'budget-entry__indicator--income' : 'budget-entry__indicator--expenses';
const sign = isIncome ? '+' : '';
const date = formatEntryDate(e.date);
return `
<div class="budget-entry" data-id="${e.id}">
<div class="budget-entry__indicator ${indClass}"></div>
<div class="budget-entry__body">
<div class="budget-entry__title">${escHtml(e.title)}</div>
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${e.is_recurring ? ' 🔁' : ''}</div>
</div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" title="Löschen">
<i data-lucide="trash-2" style="width:14px;height:14px;"></i>
</button>
</div>
`;
}).join('');
}
function formatEntryDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`;
}
// --------------------------------------------------------
// Modal
// --------------------------------------------------------
function openModal({ mode, entry = null }) {
document.getElementById('budget-modal-overlay')?.remove();
const isEdit = mode === 'edit';
const today = new Date().toISOString().slice(0, 10);
const isExpense = isEdit ? entry.amount < 0 : true; // Standard: Ausgabe
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
const catOpts = CATEGORIES.map((c) =>
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${c}</option>`
).join('');
const overlay = document.createElement('div');
overlay.id = 'budget-modal-overlay';
overlay.className = 'budget-modal-overlay';
overlay.innerHTML = `
<div class="budget-modal" role="dialog" aria-modal="true">
<div class="budget-modal__header">
<h2 class="budget-modal__title">${isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}</h2>
<button class="budget-modal__close" id="bm-close" aria-label="Schließen">
<i data-lucide="x" style="width:16px;height:16px;"></i>
</button>
</div>
<div class="budget-modal__body">
<!-- Einnahme / Ausgabe Toggle -->
<div class="amount-type-toggle">
<button class="amount-type-btn amount-type-btn--expenses ${isExpense ? 'amount-type-btn--active' : ''}"
id="type-expense" type="button">Ausgabe</button>
<button class="amount-type-btn amount-type-btn--income ${!isExpense ? 'amount-type-btn--active' : ''}"
id="type-income" type="button">Einnahme</button>
</div>
<div class="form-group">
<label class="form-label" for="bm-title">Titel *</label>
<input type="text" class="form-input" id="bm-title"
placeholder="z.B. REWE Einkauf" value="${escHtml(isEdit ? entry.title : '')}">
</div>
<div class="form-group">
<label class="form-label" for="bm-amount">Betrag (€) *</label>
<input type="number" class="form-input" id="bm-amount"
placeholder="0,00" step="0.01" min="0"
value="${absAmount}">
</div>
<div class="form-group">
<label class="form-label" for="bm-category">Kategorie</label>
<select class="form-input" id="bm-category">${catOpts}</select>
</div>
<div class="form-group">
<label class="form-label" for="bm-date">Datum *</label>
<input type="date" class="form-input" id="bm-date"
value="${isEdit ? entry.date : today}">
</div>
<div class="form-group">
<label class="allday-toggle">
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
<span class="allday-toggle__label">Wiederkehrend</span>
</label>
</div>
</div>
<div class="budget-modal__footer">
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" title="Löschen">
<i data-lucide="trash-2" style="width:16px;height:16px;"></i>
</button>` : '<div></div>'}
<div class="budget-modal__footer-actions">
<button class="btn btn--secondary" id="bm-cancel">Abbrechen</button>
<button class="btn btn--primary" id="bm-save">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
if (window.lucide) lucide.createIcons();
// Typ-Toggle
let currentType = isExpense ? 'expense' : 'income';
overlay.querySelector('#type-expense').addEventListener('click', () => {
currentType = 'expense';
overlay.querySelector('#type-expense').classList.add('amount-type-btn--active');
overlay.querySelector('#type-income').classList.remove('amount-type-btn--active');
});
overlay.querySelector('#type-income').addEventListener('click', () => {
currentType = 'income';
overlay.querySelector('#type-income').classList.add('amount-type-btn--active');
overlay.querySelector('#type-expense').classList.remove('amount-type-btn--active');
});
overlay.querySelector('#bm-close').addEventListener('click', () => overlay.remove());
overlay.querySelector('#bm-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#bm-delete')?.addEventListener('click', async () => {
if (!confirm(`"${entry.title}" wirklich löschen?`)) return;
overlay.remove();
await deleteEntry(entry.id);
});
overlay.querySelector('#bm-save').addEventListener('click', async () => {
const saveBtn = overlay.querySelector('#bm-save');
const title = overlay.querySelector('#bm-title').value.trim();
const absVal = parseFloat(overlay.querySelector('#bm-amount').value);
const category = overlay.querySelector('#bm-category').value;
const date = overlay.querySelector('#bm-date').value;
const recurring = overlay.querySelector('#bm-recurring').checked ? 1 : 0;
if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; }
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; }
if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; }
const amount = currentType === 'expense' ? -absVal : absVal;
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const body = { title, amount, category, date, is_recurring: recurring };
if (mode === 'create') {
const res = await api.post('/budget', body);
state.entries.unshift(res.data);
} else {
const res = await api.put(`/budget/${entry.id}`, body);
const idx = state.entries.findIndex((e) => e.id === entry.id);
if (idx !== -1) state.entries[idx] = res.data;
}
// Zusammenfassung neu laden
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data;
overlay.remove();
renderBody();
window.oikos?.showToast(mode === 'create' ? 'Eintrag hinzugefügt' : 'Eintrag gespeichert', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Hinzufügen';
}
});
overlay.querySelector('#bm-title').focus();
}
// --------------------------------------------------------
// Eintrag löschen
// --------------------------------------------------------
async function deleteEntry(id) {
if (!confirm('Eintrag wirklich löschen?')) return;
try {
await api.delete(`/budget/${id}`);
state.entries = state.entries.filter((e) => e.id !== id);
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data;
renderBody();
window.oikos?.showToast('Eintrag gelöscht', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
}
// --------------------------------------------------------
// Hilfsfunktion
// --------------------------------------------------------
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+316 -13
View File
@@ -1,25 +1,328 @@
/**
* Modul: Contacts
* Zweck: Seite für das Contacts-Modul
* Abhängigkeiten: /api.js
* Modul: Kontakte (Contacts)
* Zweck: Kontaktliste mit Kategorie-Filter, Suche, CRUD, tel:/mailto:/maps-Links
* Abhängigkeiten: /api.js, /router.js (window.oikos)
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges'];
const CATEGORY_ICONS = {
'Arzt': '🏥',
'Schule/Kita': '🏫',
'Behörde': '🏛️',
'Versicherung': '🛡️',
'Handwerker': '🔧',
'Notfall': '🚨',
'Sonstiges': '📋',
};
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = {
contacts: [],
activeCategory: null,
searchQuery: '',
};
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Contacts</h1>
<div class="contacts-page">
<div class="contacts-toolbar">
<div class="contacts-toolbar__search">
<i data-lucide="search" class="contacts-toolbar__search-icon"></i>
<input type="search" class="contacts-toolbar__search-input"
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
autocomplete="off">
</div>
<button class="btn btn--primary" id="contacts-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;"></i>
Neu
</button>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
<div class="contacts-filters" id="contacts-filters">
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">Alle</button>
${CATEGORIES.map((c) => `
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${escHtml(c)}</button>
`).join('')}
</div>
<div id="contacts-list" class="contacts-list"></div>
</div>
`;
if (window.lucide) lucide.createIcons();
const res = await api.get('/contacts');
state.contacts = res.data;
renderList();
// Suche
let searchTimer;
document.getElementById('contacts-search').addEventListener('input', (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.searchQuery = e.target.value.trim();
renderList();
}, 200);
});
// Kategorie-Filter
document.getElementById('contacts-filters').addEventListener('click', (e) => {
const chip = e.target.closest('[data-cat]');
if (!chip) return;
document.querySelectorAll('.contact-filter-chip').forEach((c) =>
c.classList.toggle('contact-filter-chip--active', c === chip)
);
state.activeCategory = chip.dataset.cat || null;
renderList();
});
// Neu
document.getElementById('contacts-add-btn').addEventListener('click', () =>
openModal({ mode: 'create' })
);
}
// --------------------------------------------------------
// Liste rendern
// --------------------------------------------------------
function filterContacts() {
let list = state.contacts;
if (state.activeCategory) {
list = list.filter((c) => c.category === state.activeCategory);
}
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
list = list.filter((c) =>
c.name.toLowerCase().includes(q) ||
(c.phone && c.phone.toLowerCase().includes(q)) ||
(c.email && c.email.toLowerCase().includes(q))
);
}
return list;
}
function renderList() {
const container = document.getElementById('contacts-list');
if (!container) return;
const contacts = filterContacts();
if (!contacts.length) {
container.innerHTML = `
<div class="contacts-empty">
<i data-lucide="users" style="width:48px;height:48px;color:var(--color-text-disabled);margin-bottom:var(--space-3);"></i>
<div style="font-size:var(--text-base);font-weight:600;">Keine Kontakte gefunden</div>
</div>
`;
if (window.lucide) lucide.createIcons();
return;
}
// Nach Kategorie gruppieren
const groups = {};
for (const c of contacts) {
if (!groups[c.category]) groups[c.category] = [];
groups[c.category].push(c);
}
container.innerHTML = Object.entries(groups)
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
.map(([cat, items]) => `
<div class="contact-group">
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${escHtml(cat)}</div>
${items.map((c) => renderContactItem(c)).join('')}
</div>
`).join('');
if (window.lucide) lucide.createIcons();
// Event-Delegation
container.addEventListener('click', async (e) => {
if (e.target.closest('[data-action="delete"]')) {
const id = parseInt(e.target.closest('[data-action="delete"]').dataset.id, 10);
await deleteContact(id);
return;
}
const item = e.target.closest('.contact-item[data-id]');
if (item && !e.target.closest('a') && !e.target.closest('[data-action]')) {
const c = state.contacts.find((c) => c.id === parseInt(item.dataset.id, 10));
if (c) openModal({ mode: 'edit', contact: c });
}
});
}
function renderContactItem(c) {
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" title="Anrufen"><i data-lucide="phone" style="width:16px;height:16px;"></i></a>` : '';
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" title="E-Mail"><i data-lucide="mail" style="width:16px;height:16px;"></i></a>` : '';
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" title="In Maps öffnen"><i data-lucide="map-pin" style="width:16px;height:16px;"></i></a>` : '';
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
return `
<div class="contact-item" data-id="${c.id}">
<div class="contact-item__icon">${CATEGORY_ICONS[c.category] || '📋'}</div>
<div class="contact-item__body">
<div class="contact-item__name">${escHtml(c.name)}</div>
${meta ? `<div class="contact-item__meta">${escHtml(meta)}</div>` : ''}
</div>
<div class="contact-item__actions">
${phone}${email}${maps}
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" title="Löschen">
<i data-lucide="trash-2" style="width:16px;height:16px;"></i>
</button>
</div>
</div>
`;
}
// --------------------------------------------------------
// Modal
// --------------------------------------------------------
function openModal({ mode, contact = null }) {
document.getElementById('contact-modal-overlay')?.remove();
const isEdit = mode === 'edit';
const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : '');
const catOpts = CATEGORIES.map((c) =>
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${c}</option>`
).join('');
const overlay = document.createElement('div');
overlay.id = 'contact-modal-overlay';
overlay.className = 'contact-modal-overlay';
overlay.innerHTML = `
<div class="contact-modal" role="dialog" aria-modal="true">
<div class="contact-modal__header">
<h2 class="contact-modal__title">${isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt'}</h2>
<button class="contact-modal__close" id="cm-close" aria-label="Schließen">
<i data-lucide="x" style="width:16px;height:16px;"></i>
</button>
</div>
<div class="contact-modal__body">
<div class="form-group">
<label class="form-label" for="cm-name">Name *</label>
<input type="text" class="form-input" id="cm-name" placeholder="Vollständiger Name" value="${v('name')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-category">Kategorie</label>
<select class="form-input" id="cm-category">${catOpts}</select>
</div>
<div class="form-group">
<label class="form-label" for="cm-phone">Telefon</label>
<input type="tel" class="form-input" id="cm-phone" placeholder="+49 …" value="${v('phone')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-email">E-Mail</label>
<input type="email" class="form-input" id="cm-email" placeholder="name@beispiel.de" value="${v('email')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-address">Adresse</label>
<input type="text" class="form-input" id="cm-address" placeholder="Straße, PLZ Ort" value="${v('address')}">
</div>
<div class="form-group">
<label class="form-label" for="cm-notes">Notizen</label>
<textarea class="form-input" id="cm-notes" rows="2" placeholder="Optional…">${v('notes')}</textarea>
</div>
</div>
<div class="contact-modal__footer">
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" title="Löschen">
<i data-lucide="trash-2" style="width:16px;height:16px;"></i>
</button>` : '<div></div>'}
<div style="display:flex;gap:var(--space-3);">
<button class="btn btn--secondary" id="cm-cancel">Abbrechen</button>
<button class="btn btn--primary" id="cm-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
if (window.lucide) lucide.createIcons();
overlay.querySelector('#cm-close').addEventListener('click', () => overlay.remove());
overlay.querySelector('#cm-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#cm-delete')?.addEventListener('click', async () => {
if (!confirm(`"${contact.name}" wirklich löschen?`)) return;
overlay.remove();
await deleteContact(contact.id);
});
overlay.querySelector('#cm-save').addEventListener('click', async () => {
const saveBtn = overlay.querySelector('#cm-save');
const name = overlay.querySelector('#cm-name').value.trim();
const category = overlay.querySelector('#cm-category').value;
const phone = overlay.querySelector('#cm-phone').value.trim() || null;
const email = overlay.querySelector('#cm-email').value.trim() || null;
const address = overlay.querySelector('#cm-address').value.trim() || null;
const notes = overlay.querySelector('#cm-notes').value.trim() || null;
if (!name) { window.oikos?.showToast('Name ist erforderlich', 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const body = { name, category, phone, email, address, notes };
if (mode === 'create') {
const res = await api.post('/contacts', body);
state.contacts.push(res.data);
state.contacts.sort((a, b) =>
CATEGORIES.indexOf(a.category) - CATEGORIES.indexOf(b.category) ||
a.name.localeCompare(b.name)
);
} else {
const res = await api.put(`/contacts/${contact.id}`, body);
const idx = state.contacts.findIndex((c) => c.id === contact.id);
if (idx !== -1) state.contacts[idx] = res.data;
}
overlay.remove();
renderList();
window.oikos?.showToast(mode === 'create' ? 'Kontakt gespeichert' : 'Kontakt aktualisiert', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen';
}
});
overlay.querySelector('#cm-name').focus();
}
async function deleteContact(id) {
if (!confirm('Kontakt wirklich löschen?')) return;
try {
await api.delete(`/contacts/${id}`);
state.contacts = state.contacts.filter((c) => c.id !== id);
renderList();
window.oikos?.showToast('Kontakt gelöscht', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+281 -13
View File
@@ -1,25 +1,293 @@
/**
* Modul: Notes
* Zweck: Seite für das Notes-Modul
* Abhängigkeiten: /api.js
* Modul: Pinnwand / Notizen (Notes)
* Zweck: Masonry-Grid mit farbigen Sticky Notes, Pin-Toggle, CRUD
* Abhängigkeiten: /api.js, /router.js (window.oikos)
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const NOTE_COLORS = [
'#FFEB3B', '#FFD54F', '#A5D6A7', '#80DEEA',
'#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF',
];
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = { notes: [], user: null };
// --------------------------------------------------------
// Markdown-Light Renderer
// --------------------------------------------------------
function renderMarkdownLight(text) {
if (!text) return '';
return escHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1')
.replace(/\n/g, '<br>');
}
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
export async function render(container, { user }) {
state.user = user;
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Notes</h1>
<div class="notes-page">
<div class="notes-toolbar">
<span class="notes-toolbar__title">Pinnwand</span>
<button class="btn btn--primary" id="notes-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;"></i>
Neue Notiz
</button>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
<div id="notes-grid" class="notes-grid"></div>
</div>
`;
if (window.lucide) lucide.createIcons();
const res = await api.get('/notes');
state.notes = res.data;
renderGrid();
document.getElementById('notes-add-btn').addEventListener('click', () => openModal({ mode: 'create' }));
}
// --------------------------------------------------------
// Grid
// --------------------------------------------------------
function renderGrid() {
const grid = document.getElementById('notes-grid');
if (!grid) return;
if (!state.notes.length) {
grid.innerHTML = `
<div class="notes-empty" style="columns:unset;grid-column:1/-1;">
<i data-lucide="sticky-note" class="notes-empty__icon"></i>
<div style="font-size:var(--text-lg);font-weight:600;margin-bottom:var(--space-2);">Noch keine Notizen</div>
<div style="font-size:var(--text-sm);">Klicke auf „Neue Notiz" um loszulegen.</div>
</div>
`;
if (window.lucide) lucide.createIcons();
return;
}
grid.innerHTML = state.notes.map((n) => renderNoteCard(n)).join('');
if (window.lucide) lucide.createIcons();
grid.addEventListener('click', async (e) => {
// Pin
const pinBtn = e.target.closest('[data-action="pin"]');
if (pinBtn) { e.stopPropagation(); await togglePin(parseInt(pinBtn.dataset.id, 10)); return; }
// Delete
const delBtn = e.target.closest('[data-action="delete"]');
if (delBtn) { e.stopPropagation(); await deleteNote(parseInt(delBtn.dataset.id, 10)); return; }
// Edit
const card = e.target.closest('.note-card[data-id]');
if (card) {
const note = state.notes.find((n) => n.id === parseInt(card.dataset.id, 10));
if (note) openModal({ mode: 'edit', note });
}
});
}
function renderNoteCard(note) {
const initials = note.creator_name
? note.creator_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
: '?';
const textColor = isLightColor(note.color) ? 'rgba(0,0,0,0.8)' : '#ffffff';
return `
<div class="note-card ${note.pinned ? 'note-card--pinned' : ''}"
data-id="${note.id}"
style="background-color:${escHtml(note.color)};color:${textColor};">
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
title="${note.pinned ? 'Anpinnen aufheben' : 'Anpinnen'}">
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;"></i>
</button>
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
<div class="note-card__content">${renderMarkdownLight(note.content)}</div>
<div class="note-card__footer">
<div class="note-card__creator">
<span class="note-card__avatar"
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
<span>${escHtml(note.creator_name || '')}</span>
</div>
<button class="note-card__delete" data-action="delete" data-id="${note.id}" title="Löschen">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i>
</button>
</div>
</div>
`;
}
// --------------------------------------------------------
// Modal
// --------------------------------------------------------
function openModal({ mode, note = null }) {
document.getElementById('note-modal-overlay')?.remove();
const overlay = document.createElement('div');
overlay.id = 'note-modal-overlay';
overlay.className = 'note-modal-overlay';
const isEdit = mode === 'edit';
const selColor = isEdit ? note.color : NOTE_COLORS[0];
overlay.innerHTML = `
<div class="note-modal" role="dialog" aria-modal="true">
<div class="note-modal__header">
<h2 class="note-modal__title">${isEdit ? 'Notiz bearbeiten' : 'Neue Notiz'}</h2>
<button class="note-modal__close" id="note-modal-close" aria-label="Schließen">
<i data-lucide="x" style="width:16px;height:16px;"></i>
</button>
</div>
<div class="note-modal__body">
<div class="form-group">
<label class="form-label" for="note-title">Titel (optional)</label>
<input type="text" class="form-input" id="note-title"
placeholder="Kein Titel" value="${escHtml(isEdit && note.title ? note.title : '')}">
</div>
<div class="form-group">
<label class="form-label" for="note-content">Inhalt *</label>
<textarea class="form-input" id="note-content" rows="6"
placeholder="Notiz eingeben… (** fett **, * kursiv *, - Liste)"
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Farbe</label>
<div class="note-color-picker">
${NOTE_COLORS.map((c) => `
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
data-color="${c}"
style="background-color:${c};border:2px solid ${c === '#FFFFFF' ? '#E5E5EA' : c};"
role="radio" tabindex="0" aria-label="Farbe ${c}"></div>
`).join('')}
</div>
</div>
<div class="form-group">
<label class="allday-toggle">
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
<span class="allday-toggle__label">Anpinnen (erscheint auf Dashboard)</span>
</label>
</div>
</div>
<div class="note-modal__footer">
<button class="btn btn--secondary" id="note-modal-cancel">Abbrechen</button>
<button class="btn btn--primary" id="note-modal-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
if (window.lucide) lucide.createIcons();
// Farb-Swatch
overlay.querySelectorAll('.note-color-swatch').forEach((sw) => {
sw.addEventListener('click', () => {
overlay.querySelectorAll('.note-color-swatch').forEach((s) => s.classList.remove('note-color-swatch--active'));
sw.classList.add('note-color-swatch--active');
});
});
overlay.querySelector('#note-modal-close').addEventListener('click', () => overlay.remove());
overlay.querySelector('#note-modal-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#note-modal-save').addEventListener('click', async () => {
const saveBtn = overlay.querySelector('#note-modal-save');
const title = overlay.querySelector('#note-title').value.trim() || null;
const content = overlay.querySelector('#note-content').value.trim();
const color = overlay.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0];
const pinned = overlay.querySelector('#note-pinned').checked ? 1 : 0;
if (!content) { window.oikos?.showToast('Inhalt ist erforderlich', 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
if (mode === 'create') {
const res = await api.post('/notes', { title, content, color, pinned });
state.notes.unshift(res.data);
} else {
const res = await api.put(`/notes/${note.id}`, { title, content, color, pinned });
const idx = state.notes.findIndex((n) => n.id === note.id);
if (idx !== -1) state.notes[idx] = res.data;
// Angepinnte nach oben sortieren
state.notes.sort((a, b) => b.pinned - a.pinned);
}
overlay.remove();
renderGrid();
window.oikos?.showToast(mode === 'create' ? 'Notiz erstellt' : 'Notiz gespeichert', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
saveBtn.disabled = false;
saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen';
}
});
overlay.querySelector('#note-content').focus();
}
// --------------------------------------------------------
// Aktionen
// --------------------------------------------------------
async function togglePin(id) {
try {
const res = await api.patch(`/notes/${id}/pin`, {});
const note = state.notes.find((n) => n.id === id);
if (note) note.pinned = res.data.pinned;
state.notes.sort((a, b) => b.pinned - a.pinned);
renderGrid();
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
}
async function deleteNote(id) {
if (!confirm('Notiz wirklich löschen?')) return;
try {
await api.delete(`/notes/${id}`);
state.notes = state.notes.filter((n) => n.id !== id);
renderGrid();
window.oikos?.showToast('Notiz gelöscht', 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
}
}
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function isLightColor(hex) {
if (!hex) return true;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 150;
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+402
View File
@@ -0,0 +1,402 @@
/**
* Modul: Budget-Tracker (Budget)
* Zweck: Styles für Monatsübersicht, Balkendiagramm, Transaktionsliste, Modal
* Abhängigkeiten: tokens.css, layout.css
*/
/* --------------------------------------------------------
* Seiten-Layout
* -------------------------------------------------------- */
.budget-page {
display: flex;
flex-direction: column;
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
max-width: var(--content-max-width);
margin: 0 auto;
}
@media (min-width: 1024px) {
.budget-page { height: 100dvh; }
}
/* --------------------------------------------------------
* Monat-Navigation
* -------------------------------------------------------- */
.budget-nav {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-surface);
flex-shrink: 0;
}
.budget-nav__label {
font-size: var(--text-base);
font-weight: var(--font-weight-semibold);
flex: 1;
text-align: center;
}
.budget-nav__today {
font-size: var(--text-sm);
color: var(--color-accent);
font-weight: var(--font-weight-medium);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-accent-light);
cursor: pointer;
border: none;
white-space: nowrap;
}
/* --------------------------------------------------------
* Zusammenfassungs-Karten
* -------------------------------------------------------- */
.budget-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
padding: var(--space-4);
flex-shrink: 0;
}
.budget-summary-card {
background-color: var(--color-surface);
border-radius: var(--radius-md);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
text-align: center;
}
.budget-summary-card__label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-1);
}
.budget-summary-card__amount {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
}
.budget-summary-card--income .budget-summary-card__amount { color: var(--color-success); }
.budget-summary-card--expenses .budget-summary-card__amount { color: var(--color-danger); }
.budget-summary-card--balance .budget-summary-card__amount { color: var(--color-text-primary); }
.budget-summary-card--balance-positive .budget-summary-card__amount { color: var(--color-success); }
.budget-summary-card--balance-negative .budget-summary-card__amount { color: var(--color-danger); }
/* --------------------------------------------------------
* Kategorien-Diagramm (Canvas)
* -------------------------------------------------------- */
.budget-chart-section {
padding: 0 var(--space-4) var(--space-3);
flex-shrink: 0;
}
.budget-chart-section__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin-bottom: var(--space-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.budget-chart {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.budget-bar-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.budget-bar-row__label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
width: 90px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.budget-bar-row__track {
flex: 1;
height: 10px;
background-color: var(--color-surface-2);
border-radius: var(--radius-full);
overflow: hidden;
}
.budget-bar-row__fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
.budget-bar-row__fill--income { background-color: var(--color-success); }
.budget-bar-row__fill--expenses { background-color: var(--color-danger); }
.budget-bar-row__amount {
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
width: 64px;
text-align: right;
flex-shrink: 0;
}
/* --------------------------------------------------------
* Transaktions-Liste
* -------------------------------------------------------- */
.budget-list-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border-top: 1px solid var(--color-border);
}
.budget-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4) var(--space-2);
flex-shrink: 0;
}
.budget-list-header__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.budget-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.budget-entry {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.budget-entry:hover {
background-color: var(--color-surface-2);
}
.budget-entry__indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.budget-entry__indicator--income { background-color: var(--color-success); }
.budget-entry__indicator--expenses { background-color: var(--color-danger); }
.budget-entry__body {
flex: 1;
min-width: 0;
}
.budget-entry__title {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.budget-entry__meta {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-top: 1px;
}
.budget-entry__amount {
font-size: var(--text-base);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
.budget-entry__amount--income { color: var(--color-success); }
.budget-entry__amount--expenses { color: var(--color-danger); }
.budget-entry__delete {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: none;
border: none;
cursor: pointer;
color: var(--color-text-disabled);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
min-height: unset;
}
.budget-entry:hover .budget-entry__delete {
opacity: 1;
}
.budget-entry__delete:hover {
color: var(--color-danger);
}
/* --------------------------------------------------------
* Leer-Zustand
* -------------------------------------------------------- */
.budget-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-secondary);
}
/* --------------------------------------------------------
* Modal
* -------------------------------------------------------- */
.budget-modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.5);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn var(--transition-fast) ease;
}
@media (min-width: 768px) {
.budget-modal-overlay {
align-items: center;
padding: var(--space-4);
}
}
.budget-modal {
background-color: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 90dvh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp var(--transition-base) ease;
}
@media (min-width: 768px) {
.budget-modal {
border-radius: var(--radius-lg);
max-width: 460px;
max-height: 80dvh;
}
}
.budget-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.budget-modal__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
}
.budget-modal__close {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-border);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
min-height: unset;
}
.budget-modal__body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* Einnahme/Ausgabe-Toggle */
.amount-type-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
background-color: var(--color-surface-2);
border-radius: var(--radius-sm);
padding: 3px;
}
.amount-type-btn {
padding: var(--space-2) var(--space-3);
border-radius: 6px;
border: none;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
background: none;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
min-height: unset;
}
.amount-type-btn--income.amount-type-btn--active {
background-color: var(--color-success);
color: #ffffff;
}
.amount-type-btn--expenses.amount-type-btn--active {
background-color: var(--color-danger);
color: #ffffff;
}
.budget-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.budget-modal__footer-actions {
display: flex;
gap: var(--space-3);
}
+312
View File
@@ -0,0 +1,312 @@
/**
* Modul: Kontakte (Contacts)
* Zweck: Styles für Kontaktliste, Kategorie-Chips, Kontakt-Karte, Modal
* Abhängigkeiten: tokens.css, layout.css
*/
/* --------------------------------------------------------
* Seiten-Layout
* -------------------------------------------------------- */
.contacts-page {
display: flex;
flex-direction: column;
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
max-width: var(--content-max-width);
margin: 0 auto;
}
@media (min-width: 1024px) {
.contacts-page { height: 100dvh; }
}
/* --------------------------------------------------------
* Toolbar: Suche + Filter + Neu
* -------------------------------------------------------- */
.contacts-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-surface);
flex-shrink: 0;
}
.contacts-toolbar__search {
flex: 1;
position: relative;
}
.contacts-toolbar__search-icon {
position: absolute;
left: var(--space-3);
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--color-text-disabled);
pointer-events: none;
}
.contacts-toolbar__search-input {
width: 100%;
padding: var(--space-2) var(--space-3) var(--space-2) 36px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-border);
background-color: var(--color-surface-2);
color: var(--color-text-primary);
font-size: var(--text-sm);
transition: border-color var(--transition-fast);
min-height: 36px;
}
.contacts-toolbar__search-input:focus {
outline: none;
border-color: var(--color-accent);
background-color: var(--color-surface);
}
/* --------------------------------------------------------
* Kategorie-Filter (horizontal scroll)
* -------------------------------------------------------- */
.contacts-filters {
display: flex;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
scrollbar-width: none;
flex-shrink: 0;
}
.contacts-filters::-webkit-scrollbar { display: none; }
.contact-filter-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
white-space: nowrap;
cursor: pointer;
border: 1.5px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
min-height: 30px;
}
.contact-filter-chip--active {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: #ffffff;
}
/* --------------------------------------------------------
* Kontaktliste
* -------------------------------------------------------- */
.contacts-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.contact-group {
margin-bottom: 0;
}
.contact-group__header {
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--space-2) var(--space-4) var(--space-1);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: var(--z-base);
}
.contact-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.contact-item:hover {
background-color: var(--color-surface-2);
}
.contact-item__icon {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background-color: var(--color-accent-light);
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: var(--text-lg);
}
.contact-item__body {
flex: 1;
min-width: 0;
}
.contact-item__name {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-item__meta {
font-size: var(--text-sm);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.contact-item__actions {
display: flex;
gap: var(--space-1);
flex-shrink: 0;
}
.contact-action-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
border: none;
background: var(--color-surface-2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
min-height: unset;
text-decoration: none;
}
.contact-action-btn:hover {
background-color: var(--color-accent-light);
color: var(--color-accent);
}
.contact-action-btn--call:hover { background-color: var(--color-success-light); color: var(--color-success); }
.contact-action-btn--mail:hover { background-color: var(--color-accent-light); color: var(--color-accent); }
.contact-action-btn--maps:hover { background-color: var(--color-warning-light); color: var(--color-warning); }
/* --------------------------------------------------------
* Leer-Zustand
* -------------------------------------------------------- */
.contacts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-secondary);
}
/* --------------------------------------------------------
* Modal (gleiche Struktur wie andere Module)
* -------------------------------------------------------- */
.contact-modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.5);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn var(--transition-fast) ease;
}
@media (min-width: 768px) {
.contact-modal-overlay {
align-items: center;
padding: var(--space-4);
}
}
.contact-modal {
background-color: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 90dvh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp var(--transition-base) ease;
}
@media (min-width: 768px) {
.contact-modal {
border-radius: var(--radius-lg);
max-width: 480px;
max-height: 85dvh;
}
}
.contact-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.contact-modal__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
}
.contact-modal__close {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-border);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
min-height: unset;
}
.contact-modal__body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.contact-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
+320
View File
@@ -0,0 +1,320 @@
/**
* Modul: Pinnwand / Notizen (Notes)
* Zweck: Styles für Masonry-Grid, Sticky-Note-Karten, Modal
* Abhängigkeiten: tokens.css, layout.css
*/
/* --------------------------------------------------------
* Seiten-Layout
* -------------------------------------------------------- */
.notes-page {
display: flex;
flex-direction: column;
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
max-width: var(--content-max-width);
margin: 0 auto;
}
@media (min-width: 1024px) {
.notes-page { height: 100dvh; }
}
/* --------------------------------------------------------
* Header / Toolbar
* -------------------------------------------------------- */
.notes-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-surface);
flex-shrink: 0;
gap: var(--space-3);
}
.notes-toolbar__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
}
/* --------------------------------------------------------
* Masonry-Grid
* -------------------------------------------------------- */
.notes-grid {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--space-4);
columns: 2;
column-gap: var(--space-3);
}
@media (min-width: 600px) {
.notes-grid { columns: 3; }
}
@media (min-width: 900px) {
.notes-grid { columns: 4; }
}
@media (min-width: 1200px) {
.notes-grid { columns: 5; }
}
/* --------------------------------------------------------
* Notiz-Karte
* -------------------------------------------------------- */
.note-card {
break-inside: avoid;
margin-bottom: var(--space-3);
border-radius: var(--radius-md);
padding: var(--space-3);
cursor: pointer;
position: relative;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.note-card--pinned {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.note-card__pin {
position: absolute;
top: var(--space-2);
right: var(--space-2);
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: rgba(0,0,0,0.1);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: unset;
opacity: 0;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
color: rgba(0,0,0,0.6);
}
.note-card--pinned .note-card__pin {
opacity: 1;
background: rgba(0,0,0,0.15);
}
.note-card:hover .note-card__pin {
opacity: 1;
}
.note-card__pin:hover {
background: rgba(0,0,0,0.2);
}
.note-card__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-1);
color: rgba(0,0,0,0.8);
padding-right: var(--space-6);
}
.note-card__content {
font-size: var(--text-sm);
line-height: var(--line-height-relaxed);
color: rgba(0,0,0,0.75);
word-break: break-word;
white-space: pre-wrap;
}
/* Markdown-light: fett, kursiv, Listen */
.note-card__content strong { font-weight: var(--font-weight-bold); }
.note-card__content em { font-style: italic; }
.note-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid rgba(0,0,0,0.08);
}
.note-card__creator {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: rgba(0,0,0,0.5);
}
.note-card__avatar {
width: 16px;
height: 16px;
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: var(--font-weight-bold);
color: #ffffff;
flex-shrink: 0;
}
.note-card__delete {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: rgba(0,0,0,0.08);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: unset;
color: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.note-card:hover .note-card__delete {
opacity: 1;
}
.note-card__delete:hover {
background: rgba(255,59,48,0.2);
color: var(--color-danger);
}
/* --------------------------------------------------------
* Leer-Zustand
* -------------------------------------------------------- */
.notes-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12) var(--space-6);
text-align: center;
flex: 1;
color: var(--color-text-secondary);
}
.notes-empty__icon {
width: 56px;
height: 56px;
color: var(--color-text-disabled);
margin-bottom: var(--space-4);
}
/* --------------------------------------------------------
* Modal
* -------------------------------------------------------- */
.note-modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.5);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn var(--transition-fast) ease;
}
@media (min-width: 768px) {
.note-modal-overlay {
align-items: center;
padding: var(--space-4);
}
}
.note-modal {
background-color: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 90dvh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp var(--transition-base) ease;
}
@media (min-width: 768px) {
.note-modal {
border-radius: var(--radius-lg);
max-width: 520px;
max-height: 80dvh;
}
}
.note-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.note-modal__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
}
.note-modal__close {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-border);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
min-height: unset;
}
.note-modal__body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Farb-Auswahl */
.note-color-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.note-color-swatch {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
border: 2px solid transparent;
cursor: pointer;
transition: transform var(--transition-fast), border-color var(--transition-fast);
}
.note-color-swatch:hover { transform: scale(1.15); }
.note-color-swatch--active { border-color: var(--color-text-primary); transform: scale(1.1); }
.note-modal__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
+278 -5
View File
@@ -1,13 +1,286 @@
/**
* Modul: Budget-Tracker (Budget)
* Zweck: REST-API-Routen für Einnahmen und Ausgaben
* Zweck: REST-API-Routen für Einnahmen/Ausgaben, Monatsübersicht, CSV-Export
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const VALID_CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// --------------------------------------------------------
// Statische Routen vor /:id
// --------------------------------------------------------
/**
* GET /api/v1/budget/summary
* Monatsübersicht: Einnahmen, Ausgaben, Saldo, Aufschlüsselung nach Kategorie.
* Query: ?month=YYYY-MM (default: aktueller Monat)
* Response: { data: { month, income, expenses, balance, byCategory: [] } }
*/
router.get('/summary', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7); // YYYY-MM
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const totals = db.get().prepare(`
SELECT
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS balance
FROM budget_entries
WHERE date BETWEEN ? AND ?
`).get(from, to);
const byCategory = db.get().prepare(`
SELECT category,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS total
FROM budget_entries
WHERE date BETWEEN ? AND ?
GROUP BY category
ORDER BY ABS(SUM(amount)) DESC
`).all(from, to);
res.json({
data: {
month,
income: totals.income || 0,
expenses: totals.expenses || 0,
balance: totals.balance || 0,
byCategory,
},
});
} catch (err) {
console.error('[budget/GET /summary]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/export
* Monatseinträge als CSV-Download.
* Query: ?month=YYYY-MM
* Response: text/csv
*/
router.get('/export', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
const entries = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
ORDER BY b.date ASC
`).all(from, to);
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
const rows = entries.map((e) =>
[
e.date,
`"${(e.title || '').replace(/"/g, '""')}"`,
e.amount.toFixed(2).replace('.', ','),
e.category,
e.is_recurring ? 'Ja' : 'Nein',
`"${(e.creator_name || '').replace(/"/g, '""')}"`,
].join(',')
).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="budget-${month}.csv"`);
res.send('\uFEFF' + header + rows); // BOM für Excel
} catch (err) {
console.error('[budget/GET /export]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/budget/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } });
});
// --------------------------------------------------------
// CRUD-Routen
// --------------------------------------------------------
/**
* GET /api/v1/budget
* Einträge eines Monats abrufen.
* Query: ?month=YYYY-MM&category=<cat>
* Response: { data: Entry[] }
*/
router.get('/', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
if (!/^\d{4}-\d{2}$/.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
const from = `${month}-01`;
const to = `${month}-31`;
let sql = `
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN ? AND ?
`;
const params = [from, to];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
sql += ' AND b.category = ?';
params.push(req.query.category);
}
sql += ' ORDER BY b.date DESC, b.created_at DESC';
const entries = db.get().prepare(sql).all(...params);
res.json({ data: entries });
} catch (err) {
console.error('[budget/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/budget
* Neuen Eintrag anlegen.
* Body: { title, amount, category?, date, is_recurring?, recurrence_rule? }
* Response: { data: Entry }
*/
router.post('/', (req, res) => {
try {
const {
title, amount, category = 'Sonstiges',
date, is_recurring = 0, recurrence_rule = null,
} = req.body;
if (!title || !title.trim())
return res.status(400).json({ error: 'Titel ist erforderlich', code: 400 });
if (amount === undefined || amount === null || isNaN(Number(amount)))
return res.status(400).json({ error: 'Betrag (Zahl) ist erforderlich', code: 400 });
if (!date || !DATE_RE.test(date))
return res.status(400).json({ error: 'Gültiges Datum (YYYY-MM-DD) erforderlich', code: 400 });
if (!VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
const result = db.get().prepare(`
INSERT INTO budget_entries (title, amount, category, date, is_recurring, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(), Number(amount), category, date,
is_recurring ? 1 : 0, recurrence_rule || null,
req.session.userId
);
const entry = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by
WHERE b.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: entry });
} catch (err) {
console.error('[budget/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/budget/:id
* Eintrag bearbeiten.
* Body: alle Felder optional
* Response: { data: Entry }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
const { title, amount, category, date, is_recurring, recurrence_rule } = req.body;
if (amount !== undefined && isNaN(Number(amount)))
return res.status(400).json({ error: 'Betrag muss eine Zahl sein', code: 400 });
if (date !== undefined && !DATE_RE.test(date))
return res.status(400).json({ error: 'Ungültiges Datum', code: 400 });
if (category !== undefined && !VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
db.get().prepare(`
UPDATE budget_entries
SET title = COALESCE(?, title),
amount = COALESCE(?, amount),
category = COALESCE(?, category),
date = COALESCE(?, date),
is_recurring = COALESCE(?, is_recurring),
recurrence_rule = ?
WHERE id = ?
`).run(
title?.trim() ?? null,
amount !== undefined ? Number(amount) : null,
category ?? null,
date ?? null,
is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
id
);
const updated = db.get().prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by WHERE b.id = ?
`).get(id);
res.json({ data: updated });
} catch (err) {
console.error('[budget/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/budget/:id
* Eintrag löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[budget/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
module.exports = router;
+144 -4
View File
@@ -4,10 +4,150 @@
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges'];
/**
* GET /api/v1/contacts
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
* Query: ?category=<cat>&q=<search>
* Response: { data: Contact[] }
*/
router.get('/', (req, res) => {
try {
let sql = 'SELECT * FROM contacts';
const params = [];
const where = [];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
where.push('category = ?');
params.push(req.query.category);
}
if (req.query.q) {
where.push('(name LIKE ? OR phone LIKE ? OR email LIKE ?)');
const like = `%${req.query.q}%`;
params.push(like, like, like);
}
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY category ASC, name ASC';
const contacts = db.get().prepare(sql).all(...params);
res.json({ data: contacts });
} catch (err) {
console.error('[contacts/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts
* Neuen Kontakt anlegen.
* Body: { name, category?, phone?, email?, address?, notes? }
* Response: { data: Contact }
*/
router.post('/', (req, res) => {
try {
const {
name, category = 'Sonstiges',
phone = null, email = null, address = null, notes = null,
} = req.body;
if (!name || !name.trim())
return res.status(400).json({ error: 'Name ist erforderlich', code: 400 });
if (!VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
const result = db.get().prepare(`
INSERT INTO contacts (name, category, phone, email, address, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(name.trim(), category, phone || null, email || null,
address || null, notes || null);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: contact });
} catch (err) {
console.error('[contacts/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/contacts/:id
* Kontakt bearbeiten.
* Body: alle Felder optional
* Response: { data: Contact }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
const { name, category, phone, email, address, notes } = req.body;
if (category !== undefined && !VALID_CATEGORIES.includes(category))
return res.status(400).json({ error: `Ungültige Kategorie: ${category}`, code: 400 });
db.get().prepare(`
UPDATE contacts
SET name = COALESCE(?, name),
category = COALESCE(?, category),
phone = ?,
email = ?,
address = ?,
notes = ?
WHERE id = ?
`).run(
name?.trim() ?? null,
category ?? null,
phone !== undefined ? (phone || null) : contact.phone,
email !== undefined ? (email || null) : contact.email,
address !== undefined ? (address || null) : contact.address,
notes !== undefined ? (notes || null) : contact.notes,
id
);
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
res.json({ data: updated });
} catch (err) {
console.error('[contacts/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/contacts/:id
* Kontakt löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM contacts WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[contacts/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (req, res) => {
res.json({ data: { categories: VALID_CATEGORIES } });
});
module.exports = router;
+142 -5
View File
@@ -1,13 +1,150 @@
/**
* Modul: Pinnwand / Notizen (Notes)
* Zweck: REST-API-Routen für Notizen
* Zweck: REST-API-Routen für Notizen (CRUD, Pin-Toggle)
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
'use strict';
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
const express = require('express');
const router = express.Router();
const db = require('../db');
const COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
/**
* GET /api/v1/notes
* Alle Notizen, angepinnte zuerst, dann nach updated_at DESC.
* Response: { data: Note[] }
*/
router.get('/', (req, res) => {
try {
const notes = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n
LEFT JOIN users u ON u.id = n.created_by
ORDER BY n.pinned DESC, n.updated_at DESC
`).all();
res.json({ data: notes });
} catch (err) {
console.error('[notes/GET /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/notes
* Neue Notiz anlegen.
* Body: { content, title?, color?, pinned? }
* Response: { data: Note }
*/
router.post('/', (req, res) => {
try {
const { content, title = null, color = '#FFEB3B', pinned = 0 } = req.body;
if (!content || !content.trim())
return res.status(400).json({ error: 'Inhalt ist erforderlich', code: 400 });
if (!COLOR_RE.test(color))
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
const result = db.get().prepare(`
INSERT INTO notes (content, title, color, pinned, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(content.trim(), title?.trim() || null, color, pinned ? 1 : 0, req.session.userId);
const note = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n LEFT JOIN users u ON u.id = n.created_by
WHERE n.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: note });
} catch (err) {
console.error('[notes/POST /]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/notes/:id
* Notiz bearbeiten.
* Body: { content?, title?, color?, pinned? }
* Response: { data: Note }
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const note = db.get().prepare('SELECT * FROM notes WHERE id = ?').get(id);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
const { content, title, color, pinned } = req.body;
if (color !== undefined && !COLOR_RE.test(color))
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
db.get().prepare(`
UPDATE notes
SET content = COALESCE(?, content),
title = ?,
color = COALESCE(?, color),
pinned = COALESCE(?, pinned)
WHERE id = ?
`).run(
content?.trim() ?? null,
title !== undefined ? (title?.trim() || null) : note.title,
color ?? null,
pinned !== undefined ? (pinned ? 1 : 0) : null,
id
);
const updated = db.get().prepare(`
SELECT n.*, u.display_name AS creator_name, u.avatar_color AS creator_color
FROM notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?
`).get(id);
res.json({ data: updated });
} catch (err) {
console.error('[notes/PUT /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PATCH /api/v1/notes/:id/pin
* Pin-Status toggeln.
* Response: { data: { id, pinned } }
*/
router.patch('/:id/pin', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const note = db.get().prepare('SELECT pinned FROM notes WHERE id = ?').get(id);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
const newPinned = note.pinned ? 0 : 1;
db.get().prepare('UPDATE notes SET pinned = ? WHERE id = ?').run(newPinned, id);
res.json({ data: { id, pinned: newPinned } });
} catch (err) {
console.error('[notes/PATCH /:id/pin]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/notes/:id
* Notiz löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM notes WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Notiz nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
console.error('[notes/DELETE /:id]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
module.exports = router;
+310
View File
@@ -0,0 +1,310 @@
/**
* Modul: Notes / Contacts / Budget — Tests
* Zweck: Validiert CRUD, Constraints, Filterabfragen, Aggregation für alle drei Module
* Ausführen: node --experimental-sqlite test-notes-contacts-budget.js
*/
'use strict';
const { DatabaseSync } = require('node:sqlite');
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
let passed = 0;
let failed = 0;
function test(name, fn) {
try { fn(); console.log(`${name}`); passed++; }
catch (err) { console.error(`${name}: ${err.message}`); failed++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
const db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);`);
db.exec(MIGRATIONS_SQL[1]);
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
const uid = u1.lastInsertRowid;
// ============================================================
// NOTES
// ============================================================
console.log('\n[Notes-Test] Notizen, Pin, Sortierung\n');
let noteId1, noteId2, noteId3;
test('Notiz erstellen', () => {
const r = db.prepare(`INSERT INTO notes (content, color, pinned, created_by)
VALUES ('Einkaufen nicht vergessen', '#FFEB3B', 0, ?)`).run(uid);
noteId1 = r.lastInsertRowid;
assert(noteId1 > 0);
});
test('Zweite Notiz mit Titel erstellen', () => {
const r = db.prepare(`INSERT INTO notes (title, content, color, pinned, created_by)
VALUES ('Wichtig', 'Arzttermin morgen', '#90CAF9', 1, ?)`).run(uid);
noteId2 = r.lastInsertRowid;
assert(noteId2 > 0);
});
test('Dritte Notiz erstellen', () => {
const r = db.prepare(`INSERT INTO notes (content, color, created_by)
VALUES ('Notiz drei', '#A5D6A7', ?)`).run(uid);
noteId3 = r.lastInsertRowid;
assert(noteId3 > 0);
});
test('Sortierung: Angepinnte zuerst', () => {
const notes = db.prepare(`
SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC
`).all();
assert(notes.length === 3);
assert(notes[0].pinned === 1, `Erste Notiz muss angeheftet sein, ist: ${notes[0].pinned}`);
});
test('Notiz aktualisieren (Inhalt + Farbe)', () => {
db.prepare(`UPDATE notes SET content = 'Neuer Inhalt', color = '#FF9500' WHERE id = ?`).run(noteId1);
const n = db.prepare('SELECT content, color FROM notes WHERE id = ?').get(noteId1);
assert(n.content === 'Neuer Inhalt');
assert(n.color === '#FF9500');
});
test('Pin-Toggle: pinned 0 → 1', () => {
const before = db.prepare('SELECT pinned FROM notes WHERE id = ?').get(noteId1);
const newPin = before.pinned ? 0 : 1;
db.prepare('UPDATE notes SET pinned = ? WHERE id = ?').run(newPin, noteId1);
const after = db.prepare('SELECT pinned FROM notes WHERE id = ?').get(noteId1);
assert(after.pinned === 1, 'Jetzt angeheftet');
});
test('Notiz löschen', () => {
db.prepare('DELETE FROM notes WHERE id = ?').run(noteId3);
const n = db.prepare('SELECT * FROM notes WHERE id = ?').get(noteId3);
assert(!n, 'Notiz gelöscht');
});
test('Verbleibende Notizen nach Löschung: 2', () => {
const notes = db.prepare('SELECT * FROM notes').all();
assert(notes.length === 2, `Erwartet 2, erhalten ${notes.length}`);
});
test('JOIN: Ersteller-Name verfügbar', () => {
const n = db.prepare(`
SELECT n.*, u.display_name AS creator_name
FROM notes n LEFT JOIN users u ON u.id = n.created_by
WHERE n.id = ?
`).get(noteId2);
assert(n.creator_name === 'Admin');
});
test('Index idx_notes_pinned genutzt', () => {
const plan = db.prepare(`EXPLAIN QUERY PLAN SELECT * FROM notes WHERE pinned = 1`).all();
const usesIndex = plan.some((r) => (r.detail || '').includes('INDEX'));
assert(usesIndex, JSON.stringify(plan));
});
// ============================================================
// CONTACTS
// ============================================================
console.log('\n[Contacts-Test] CRUD, Kategorien, Suche\n');
let cId1, cId2, cId3;
test('Kontakt erstellen (Arzt)', () => {
const r = db.prepare(`INSERT INTO contacts (name, category, phone, email)
VALUES ('Dr. Müller', 'Arzt', '+49 30 12345', 'mueller@praxis.de')`).run();
cId1 = r.lastInsertRowid;
assert(cId1 > 0);
});
test('Kontakt erstellen (Notfall)', () => {
const r = db.prepare(`INSERT INTO contacts (name, category, phone)
VALUES ('Feuerwehr', 'Notfall', '112')`).run();
cId2 = r.lastInsertRowid;
assert(cId2 > 0);
});
test('Kontakt erstellen (Handwerker)', () => {
const r = db.prepare(`INSERT INTO contacts (name, category, phone, address)
VALUES ('Klempner Fritz', 'Handwerker', '+49 170 99999', 'Musterstr. 1, Berlin')`).run();
cId3 = r.lastInsertRowid;
assert(cId3 > 0);
});
test('Alle Kontakte abrufen', () => {
const contacts = db.prepare('SELECT * FROM contacts ORDER BY category ASC, name ASC').all();
assert(contacts.length === 3);
});
test('Nach Kategorie filtern (Arzt)', () => {
const contacts = db.prepare(`SELECT * FROM contacts WHERE category = 'Arzt'`).all();
assert(contacts.length === 1);
assert(contacts[0].name === 'Dr. Müller');
});
test('Volltextsuche nach Name', () => {
const q = '%Feuerwehr%';
const contacts = db.prepare(`
SELECT * FROM contacts WHERE name LIKE ? OR phone LIKE ? OR email LIKE ?
`).all(q, q, q);
assert(contacts.length === 1);
assert(contacts[0].category === 'Notfall');
});
test('Suche nach Telefonnummer', () => {
const q = '%112%';
const contacts = db.prepare(`SELECT * FROM contacts WHERE phone LIKE ?`).all(q);
assert(contacts.length === 1);
});
test('Kontakt aktualisieren', () => {
db.prepare(`UPDATE contacts SET phone = '+49 30 99999' WHERE id = ?`).run(cId1);
const c = db.prepare('SELECT phone FROM contacts WHERE id = ?').get(cId1);
assert(c.phone === '+49 30 99999');
});
test('Kontakt löschen', () => {
db.prepare('DELETE FROM contacts WHERE id = ?').run(cId3);
const c = db.prepare('SELECT * FROM contacts WHERE id = ?').get(cId3);
assert(!c, 'Kontakt gelöscht');
});
// ============================================================
// BUDGET
// ============================================================
console.log('\n[Budget-Test] Einnahmen, Ausgaben, Saldo, Aggregation, CSV-Vorbereitung\n');
let bId1, bId2, bId3, bId4;
test('Ausgabe eintragen (Lebensmittel)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
VALUES ('REWE', -85.40, 'Lebensmittel', '2026-03-10', ?)`).run(uid);
bId1 = r.lastInsertRowid;
assert(bId1 > 0);
});
test('Einnahme eintragen (Gehalt)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
VALUES ('Gehalt März', 2800.00, 'Sonstiges', '2026-03-01', ?)`).run(uid);
bId2 = r.lastInsertRowid;
assert(bId2 > 0);
});
test('Ausgabe (Miete)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, is_recurring, created_by)
VALUES ('Miete', -950.00, 'Miete', '2026-03-01', 1, ?)`).run(uid);
bId3 = r.lastInsertRowid;
assert(bId3 > 0);
});
test('Ausgabe im anderen Monat (April)', () => {
const r = db.prepare(`INSERT INTO budget_entries (title, amount, category, date, created_by)
VALUES ('Strom April', -55.00, 'Sonstiges', '2026-04-15', ?)`).run(uid);
bId4 = r.lastInsertRowid;
assert(bId4 > 0);
});
test('Monatsfilter März: nur März-Einträge', () => {
const entries = db.prepare(`
SELECT * FROM budget_entries WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY date ASC
`).all();
assert(entries.length === 3, `Erwartet 3, erhalten ${entries.length}`);
});
test('Monatsfilter April: nur April-Eintrag', () => {
const entries = db.prepare(`
SELECT * FROM budget_entries WHERE date BETWEEN '2026-04-01' AND '2026-04-30'
`).all();
assert(entries.length === 1);
assert(entries[0].title === 'Strom April');
});
test('Einnahmen-Summe März', () => {
const row = db.prepare(`
SELECT SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income
FROM budget_entries WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
`).get();
assert(Math.abs(row.income - 2800.00) < 0.01, `Einnahmen: ${row.income}`);
});
test('Ausgaben-Summe März', () => {
const row = db.prepare(`
SELECT SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses
FROM budget_entries WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
`).get();
const expected = -(85.40 + 950.00);
assert(Math.abs(row.expenses - expected) < 0.01, `Ausgaben: ${row.expenses}`);
});
test('Saldo März positiv', () => {
const row = db.prepare(`
SELECT SUM(amount) AS balance
FROM budget_entries WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
`).get();
assert(row.balance > 0, `Saldo: ${row.balance}`);
});
test('Aggregation nach Kategorie', () => {
const cats = db.prepare(`
SELECT category,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
SUM(amount) AS total
FROM budget_entries
WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
GROUP BY category ORDER BY ABS(SUM(amount)) DESC
`).all();
assert(cats.length >= 2, `Mindestens 2 Kategorien, erhalten ${cats.length}`);
// Miete sollte die größte Ausgabe sein
const miete = cats.find((c) => c.category === 'Miete');
assert(miete, 'Miete in Kategorien vorhanden');
assert(Math.abs(miete.expenses + 950.00) < 0.01, `Miete-Ausgaben: ${miete.expenses}`);
});
test('Wiederkehrend-Flag korrekt', () => {
const r = db.prepare('SELECT is_recurring FROM budget_entries WHERE id = ?').get(bId3);
assert(r.is_recurring === 1, 'Miete ist wiederkehrend');
});
test('Eintrag aktualisieren', () => {
db.prepare(`UPDATE budget_entries SET amount = -90.50 WHERE id = ?`).run(bId1);
const e = db.prepare('SELECT amount FROM budget_entries WHERE id = ?').get(bId1);
assert(Math.abs(e.amount + 90.50) < 0.01);
});
test('Eintrag löschen', () => {
db.prepare('DELETE FROM budget_entries WHERE id = ?').run(bId4);
const e = db.prepare('SELECT * FROM budget_entries WHERE id = ?').get(bId4);
assert(!e, 'Eintrag gelöscht');
});
test('CSV-Vorbereitung: alle März-Einträge mit JOIN', () => {
const entries = db.prepare(`
SELECT b.*, u.display_name AS creator_name
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
WHERE b.date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY b.date ASC
`).all();
assert(entries.length === 3);
assert(entries[0].creator_name === 'Admin');
});
test('Index idx_budget_date genutzt', () => {
const plan = db.prepare(`
EXPLAIN QUERY PLAN SELECT * FROM budget_entries WHERE date BETWEEN '2026-03-01' AND '2026-03-31'
`).all();
const usesIndex = plan.some((r) => (r.detail || '').includes('INDEX'));
assert(usesIndex, JSON.stringify(plan));
});
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------
console.log(`\n[Notes/Contacts/Budget-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
if (failed > 0) process.exit(1);