Steuerkram/public/static/js/app.js
fds2610-bot 262454e038 Feature: Belegbuchung verbessert, Metadaten bereinigt (Migrationen 024–026)
- Verbindlichkeiten in Kategorie-Auswahl bei Belegbuchung ergänzt
- Direkte Kontonummer-Eingabe über Suchfeld (ohne Dropdown-Auswahl)
- Auto-Select im Konto-Suchfeld nur noch bei exaktem Kontonummer-Treffer
- Flash-Messages bleiben 20 Sekunden sichtbar
- Bewirtungsfelder (Grund, Teilnehmerliste) am Beleg statt an Buchung gespeichert (Migration 024)
- Bewirtungssplit für Konto 4652→4657 analog zu 4650→4654; USt 0% für 4652/4657 automatisch
- Bewirtungshinweis als persistentes Flash (verschwindet nicht automatisch)
- Eingangsdatum aus Belegen entfernt (Migration 025)
- Bruttobetrag am Beleg wird aus Buchungszeilen berechnet, keine manuelle Eingabe mehr
- Kontenrahmen von Produktion übernommen (Migration 026)
2026-04-11 11:43:28 +02:00

377 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Steuerkram – App JavaScript
* Kein Framework. Nur native DOM-APIs.
*/
'use strict';
// ── Kontenart-Badge neben Label anzeigen ─────────────────────
function showKontoArt(selectId, badgeId, sel) {
const badge = document.getElementById(badgeId);
if (!badge) return;
const opt = sel.options[sel.selectedIndex];
const art = opt?.dataset?.art || '';
const classes = {
'Aufwand': 'badge-red',
'Ertrag': 'badge-green',
'Aktiv': 'badge-blue',
'Passiv': 'badge-yellow',
'Neutral': 'badge-gray',
};
badge.textContent = art;
badge.className = 'badge ' + (classes[art] || 'badge-gray');
badge.style.display = art ? '' : 'none';
}
// ── Konto-Filter (Select-Filterung per Sucheingabe) ───────────
document.addEventListener('input', (e) => {
if (!e.target.classList.contains('konto-filter')) return;
const q = e.target.value.toLowerCase();
const select = document.getElementById(e.target.dataset.target);
if (!select) return;
for (const opt of select.options) {
if (opt.value === '') { opt.hidden = false; continue; }
opt.hidden = !opt.dataset.search.toLowerCase().includes(q);
}
// Auto-select nur bei exaktem Kontonummer-Treffer; sonst Select leeren
// (verhindert, dass direkte Kontonummer-Eingabe durch einen Partial-Match überschrieben wird)
if (q.length > 0) {
const exact = [...select.options].find(
opt => opt.value !== '' && opt.dataset.search && opt.dataset.search.split(' ')[0] === q
);
select.value = exact ? exact.value : '';
select.dispatchEvent(new Event('change'));
}
});
// ── Modals ────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Open modal
const openBtn = e.target.closest('[data-modal-open]');
if (openBtn) {
const id = openBtn.dataset.modalOpen;
document.getElementById(id)?.classList.add('open');
return;
}
// Close modal via backdrop click or close button
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
closeBtn.closest('.modal-overlay')?.classList.remove('open');
return;
}
if (e.target.classList.contains('modal-overlay')) {
e.target.classList.remove('open');
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
}
});
// ── Confirm Delete ────────────────────────────────────────────
document.addEventListener('submit', (e) => {
const form = e.target;
const msg = form.dataset.confirm;
if (msg && !confirm(msg)) {
e.preventDefault();
}
});
// ── Rechnungszeilen – Live-Kalkulation ────────────────────────
function calcInvoiceLine(row) {
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
const netto = menge * preis;
const ust = netto * ustSatz / 100;
const brutto = netto + ust;
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const nettoEl = row.querySelector('.line-netto');
const bruttoEl = row.querySelector('.line-brutto');
if (nettoEl) nettoEl.textContent = fmt(netto) + ' €';
if (bruttoEl) bruttoEl.textContent = fmt(brutto) + ' €';
}
function calcInvoiceTotals() {
let totalNetto = 0;
let totalUst = 0;
let totalBrutto = 0;
document.querySelectorAll('.invoice-line-row').forEach(row => {
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
const netto = menge * preis;
const ust = netto * ustSatz / 100;
totalNetto += netto;
totalUst += ust;
totalBrutto += netto + ust;
calcInvoiceLine(row);
});
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
const elNetto = document.getElementById('total-netto');
const elUst = document.getElementById('total-ust');
const elBrutto = document.getElementById('total-brutto');
if (elNetto) elNetto.textContent = fmt(totalNetto);
if (elUst) elUst.textContent = fmt(totalUst);
if (elBrutto) elBrutto.textContent = fmt(totalBrutto);
}
// Live-Update on input change in invoice form
document.addEventListener('input', (e) => {
if (e.target.closest('.invoice-line-row')) {
calcInvoiceTotals();
}
if (e.target.closest('.invoice-line-row') && e.target.matches('[name$="[ust_satz]"]')) {
calcInvoiceTotals();
}
});
// ── Rechnungszeile hinzufügen ─────────────────────────────────
let lineCounter = 0;
function addInvoiceLine() {
lineCounter++;
const tbody = document.getElementById('invoice-lines-body');
if (!tbody) return;
const idx = tbody.querySelectorAll('.invoice-line-row').length;
const row = document.createElement('tr');
row.className = 'invoice-line-row';
row.innerHTML = `
<td><span class="line-pos text-muted">${idx + 1}</span></td>
<td><input type="text" name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" style="min-width:200px"></td>
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:80px" required></td>
<td>
<select name="lines[${idx}][einheit]" style="width:80px">
<option value="Stk.">Stk.</option>
<option value="h">h</option>
<option value="Tage">Tage</option>
<option value="Monat">Monat</option>
<option value="Pauschale">Pauschale</option>
<option value="km">km</option>
</select>
</td>
<td>
<select name="lines[${idx}][ust_satz]" style="width:80px">
<option value="19">19 %</option>
<option value="7">7 %</option>
<option value="0">0 %</option>
</select>
</td>
<td><input type="number" name="lines[${idx}][einzelpreis]" value="0.00" min="0" step="0.01" style="width:110px" required></td>
<td class="td-right line-netto text-muted">0,00 €</td>
<td class="td-right line-brutto font-bold">0,00 €</td>
<td>
<button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button>
</td>`;
tbody.appendChild(row);
calcInvoiceTotals();
}
function removeInvoiceLine(btn) {
btn.closest('.invoice-line-row').remove();
// Renumber positions
document.querySelectorAll('.invoice-line-row').forEach((row, i) => {
const posEl = row.querySelector('.line-pos');
if (posEl) posEl.textContent = i + 1;
// Update indices in name attributes
row.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/lines\[\d+\]/, `lines[${i}]`);
});
});
calcInvoiceTotals();
}
// Init totals on page load
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('invoice-lines-body')) {
calcInvoiceTotals();
}
});
// ── Konto-Suche (Typeahead) ───────────────────────────────────
function setupAccountSearch(inputId, hiddenId) {
const input = document.getElementById(inputId);
const hidden = document.getElementById(hiddenId);
if (!input || !hidden) return;
let dropdown = null;
let debounce = null;
input.addEventListener('input', () => {
clearTimeout(debounce);
const q = input.value.trim();
if (q.length < 1) { closeDropdown(); return; }
debounce = setTimeout(async () => {
const res = await fetch(`/accounts/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
renderDropdown(data);
}, 200);
});
function renderDropdown(items) {
closeDropdown();
if (!items.length) return;
dropdown = document.createElement('ul');
dropdown.className = 'account-dropdown';
dropdown.style.cssText = `
position:absolute; z-index:50; background:#fff; border:1px solid var(--color-border);
border-radius:var(--radius); width:100%; max-height:220px; overflow-y:auto;
list-style:none; padding:0; margin:0; box-shadow:var(--shadow);
`;
items.forEach(item => {
const li = document.createElement('li');
li.style.cssText = 'padding:.5rem .75rem; cursor:pointer; font-size:.875rem; border-bottom:1px solid var(--color-border)';
li.innerHTML = `<strong>${item.kontonummer}</strong> – ${item.bezeichnung} <small style="color:var(--color-muted)">(${item.kontenart})</small>`;
li.addEventListener('mousedown', () => {
input.value = `${item.kontonummer} – ${item.bezeichnung}`;
hidden.value = item.id;
closeDropdown();
});
dropdown.appendChild(li);
});
const wrapper = input.closest('.account-search-wrap') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.appendChild(dropdown);
}
function closeDropdown() {
dropdown?.remove();
dropdown = null;
}
input.addEventListener('blur', () => setTimeout(closeDropdown, 150));
}
// ── Inline-Edit (Belegdatum / Rechnungsnr. in der Info-Bar) ──────
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.inline-edit-trigger');
if (!trigger || trigger.querySelector('input')) return;
const bar = trigger.closest('[data-beleg-id]');
if (!bar) return;
const field = trigger.dataset.field;
const type = trigger.dataset.type || 'text';
const rawValue = trigger.dataset.raw || '';
const input = document.createElement('input');
input.type = type;
input.value = rawValue;
input.style.cssText = 'font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:' + (type === 'date' ? '140px' : '180px');
if (field === 'supplier_name') {
input.setAttribute('list', 'supplier-datalist');
input.setAttribute('autocomplete', 'off');
}
const originalHTML = trigger.innerHTML;
trigger.innerHTML = '';
trigger.appendChild(input);
input.focus();
if (type === 'text') input.select();
let cancelled = false;
async function save() {
if (cancelled) return;
const newValue = input.value.trim();
const body = new URLSearchParams({
rechnungs_nr: field === 'rechnungs_nr' ? newValue : bar.dataset.rechnungsNr || '',
belegtyp: bar.dataset.belegtyp || '',
beleg_datum: field === 'beleg_datum' ? newValue : bar.dataset.belegDatum || '',
betrag_brutto: bar.dataset.betragBrutto || '',
supplier_name: field === 'supplier_name' ? newValue : bar.dataset.supplierName || '',
});
try {
const res = await fetch(`/belege/${bar.dataset.belegId}/meta`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) throw new Error();
// Update stored data-attribute
if (field === 'beleg_datum') {
bar.dataset.belegDatum = newValue;
trigger.dataset.raw = newValue;
if (newValue) {
const [y, m, d] = newValue.split('-');
trigger.textContent = `${d}.${m}.${y}`;
} else {
trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
}
} else if (field === 'supplier_name') {
bar.dataset.supplierName = newValue;
trigger.dataset.raw = newValue;
trigger.textContent = newValue || '';
if (!newValue) trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
} else {
bar.dataset.rechnungsNr = newValue;
trigger.dataset.raw = newValue;
trigger.textContent = newValue || '';
if (!newValue) trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
}
} catch (_) {
trigger.innerHTML = originalHTML;
}
}
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { cancelled = true; trigger.innerHTML = originalHTML; }
});
});
// ── Konto-Select: Doppelklick / Enter → Kontonr. ins Filterfeld ──
function transferKontoNr(select) {
const opt = select.options[select.selectedIndex];
if (!opt || !opt.value) return;
const kontoNr = opt.textContent.split(' – ')[0].trim();
const filterInput = document.querySelector(`.konto-filter[data-target="${select.id}"]`);
if (filterInput) filterInput.value = kontoNr;
}
document.addEventListener('dblclick', (e) => {
const select = e.target.closest('select#kategorie_konto_id, select#zahlungskonto_id');
if (select) transferKontoNr(select);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const select = e.target.closest('select#kategorie_konto_id, select#zahlungskonto_id');
if (select) {
e.preventDefault();
transferKontoNr(select);
}
});
// ── Flash Message Auto-dismiss ────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.flash').forEach(el => {
if (el.closest('#ki-badge')) return; // KI-Badge nie auto-dismissen
if (el.classList.contains('flash-persistent')) return; // z.B. Bewirtungshinweis
setTimeout(() => {
el.style.transition = 'opacity .4s';
el.style.opacity = '0';
setTimeout(() => el.remove(), 400);
}, 20000);
});
});