Steuerkram/public/static/js/app.js

586 lines
24 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'));
}
});
// ── Klickbare Tabellenzeilen (Navigation) ──────────────────────
document.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-row-href]');
if (!row || e.target.closest('[data-no-row-click]')) return;
window.location = row.dataset.rowHref;
});
// ── 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 / Überschrift hinzufügen ───────────────────
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
switch (el.dataset.action) {
case 'show-add-line-dialog': showAddLineDialog(el); break;
case 'add-invoice-line': document.getElementById('add-line-popup')?.remove(); addInvoiceLine(); break;
case 'add-invoice-heading': document.getElementById('add-line-popup')?.remove(); addInvoiceHeading(); break;
case 'remove-invoice-line': removeInvoiceLine(el); break;
}
});
function showAddLineDialog(btn) {
document.getElementById('add-line-popup')?.remove();
const popup = document.createElement('div');
popup.id = 'add-line-popup';
popup.style.cssText = 'position:absolute;z-index:200;background:#fff;border:1px solid #ccc;border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.12);overflow:hidden;font-size:.875rem';
popup.innerHTML = `
<button class="add-line-opt" data-action="add-invoice-heading">Überschrift</button>
<button class="add-line-opt" data-action="add-invoice-line">Neue Position</button>`;
document.body.appendChild(popup);
const r = btn.getBoundingClientRect();
popup.style.top = (window.scrollY + r.bottom + 3) + 'px';
popup.style.left = r.left + 'px';
popup.style.minWidth = r.width + 'px';
setTimeout(() => {
document.addEventListener('click', function close(e) {
if (!popup.contains(e.target) && e.target !== btn) {
popup.remove();
document.removeEventListener('click', close);
}
});
}, 0);
}
function _nextLineIdx() {
const tbody = document.getElementById('invoice-lines-body');
return tbody ? tbody.querySelectorAll('tr').length : 0;
}
function addInvoiceLine() {
const tbody = document.getElementById('invoice-lines-body');
if (!tbody) return;
const idx = _nextLineIdx();
const row = document.createElement('tr');
row.className = 'invoice-line-row';
row.innerHTML = `
<td><span class="line-pos text-muted"></span></td>
<td>
<input type="hidden" name="lines[${idx}][type]" value="position">
<textarea name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" rows="1" class="line-bez-input" style="width:100%"></textarea>
</td>
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:100%" required></td>
<td>
<select name="lines[${idx}][einheit]" style="width:100%">
<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:100%">
<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:100%" 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" data-action="remove-invoice-line">✕</button>
</td>`;
tbody.appendChild(row);
renumberLines();
calcInvoiceTotals();
const ta = row.querySelector('.line-bez-input');
if (ta) { initBezTextarea(ta); ta.focus(); }
}
function addInvoiceHeading() {
const tbody = document.getElementById('invoice-lines-body');
if (!tbody) return;
const idx = _nextLineIdx();
const row = document.createElement('tr');
row.className = 'invoice-heading-row';
row.innerHTML = `
<td colspan="8">
<input type="hidden" name="lines[${idx}][type]" value="heading">
<textarea name="lines[${idx}][bezeichnung]" required placeholder="Überschrift" rows="1" class="line-bez-input" style="width:100%;font-weight:bold"></textarea>
</td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>`;
tbody.appendChild(row);
renumberLines();
const ta = row.querySelector('.line-bez-input');
if (ta) { initBezTextarea(ta); ta.focus(); }
}
function removeInvoiceLine(btn) {
btn.closest('tr').remove();
renumberLines();
calcInvoiceTotals();
}
function renumberLines() {
const tbody = document.getElementById('invoice-lines-body');
if (!tbody) return;
let posNum = 0;
tbody.querySelectorAll('tr').forEach((row, i) => {
// Alle name-Attribute auf neuen Array-Index setzen
row.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/lines\[\d+\]/, `lines[${i}]`);
});
// Positions-Nummer nur bei echten Zeilen hochzählen
const posEl = row.querySelector('.line-pos');
if (posEl) posEl.textContent = ++posNum;
});
}
// 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)';
// Kontodaten nur als Text einfügen (Fix #213)
const nr = document.createElement('strong');
nr.textContent = item.kontonummer;
const art = document.createElement('small');
art.className = 'text-muted';
art.textContent = `(${item.kontenart})`;
li.append(nr, ` – ${item.bezeichnung} `, art);
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 ──
// selectedIndex ist beim click-Event garantiert korrekt; beim dblclick-Event
// kann er in manchen Browsern bei List-Boxes zurückgesetzt sein (kein Prior-Select),
// daher wird er beim click gecacht und als Fallback genutzt.
const _kontoNrCache = {};
document.addEventListener('click', (e) => {
const sel = e.target.closest('select#kategorie_konto_id, select#zahlungskonto_id');
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
if (opt && opt.value) _kontoNrCache[sel.id] = opt.textContent.split(' – ')[0].trim();
});
function transferKontoNr(select) {
const opt = select.options[select.selectedIndex];
const kontoNr = (opt && opt.value)
? opt.textContent.split(' – ')[0].trim()
: (_kontoNrCache[select.id] ?? null);
if (!kontoNr) return;
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);
}
});
// ── Rechnungsformular: Enter nicht als Submit, Textarea auto-resize ───────────
function initBezTextarea(el) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
document.addEventListener('input', (e) => {
if (e.target.classList.contains('line-bez-input')) initBezTextarea(e.target);
if (e.target.classList.contains('autosize-textarea')) initBezTextarea(e.target);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
if (e.target.tagName === 'TEXTAREA') return;
const form = e.target.closest('form.invoice-form');
if (form) e.preventDefault();
}, true);
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.line-bez-input').forEach(initBezTextarea);
});
// ── 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);
});
});
// ── Buchungsvorlagen ──────────────────────────────────────────
// Vorlage auswählen → Modal per AJAX laden
document.addEventListener('change', function(e) {
const sel = e.target.closest('#vorlage-select');
if (!sel) return;
const vorlageId = sel.value;
if (!vorlageId) return;
const belegId = sel.dataset.belegId || '';
const bankTxId = sel.dataset.bankTx || '';
let url = `/api/buchungsvorlagen/${vorlageId}/modal?beleg_id=${belegId}`;
if (bankTxId) url += `&bank_tx=${bankTxId}`;
fetch(url)
.then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(html => {
const container = document.getElementById('vorlage-modal-container');
if (!container) return;
container.innerHTML = html;
// CSRF-Token in das dynamisch geladene Formular injizieren
const token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
container.querySelectorAll('form').forEach(function(form) {
if (token && !form.querySelector('[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
form.appendChild(inp);
}
});
})
.catch(() => {
// Selektion zurücksetzen bei Fehler
sel.value = '';
});
});
// Modal schließen → Vorlage-Select zurücksetzen
document.addEventListener('click', function(e) {
const closeBtn = e.target.closest('[data-modal-close]');
if (!closeBtn) return;
const overlay = closeBtn.closest('.modal-overlay');
if (overlay && overlay.id === 'vorlage-modal') {
const sel = document.getElementById('vorlage-select');
if (sel) sel.value = '';
const container = document.getElementById('vorlage-modal-container');
if (container) setTimeout(() => { container.innerHTML = ''; }, 300);
}
});
// Live-Berechnungen in USt-Zeilen des Vorlage-Modals
document.addEventListener('input', function(e) {
const inp = e.target;
if (!inp.matches('#vorlage-modal .vorlage-netto, #vorlage-modal .vorlage-brutto')) return;
const row = inp.closest('.vorlage-ust-row');
if (!row) return;
const pDec = v => parseFloat(String(v).replace(',', '.')) || 0;
const satz = parseFloat(row.dataset.ustSatz) / 100;
const netto = row.querySelector('.vorlage-netto');
const brutto = row.querySelector('.vorlage-brutto');
const ust = row.querySelector('.vorlage-ust');
const ustAnzeige = row.querySelector('.vorlage-ust-anzeige');
if (inp.classList.contains('vorlage-netto')) {
const n = pDec(netto.value);
brutto.value = (n * (1 + satz)).toFixed(2);
ust.value = (n * satz).toFixed(2);
} else {
const b = pDec(brutto.value);
const n = b / (1 + satz);
netto.value = n.toFixed(2);
ust.value = (b - n).toFixed(2);
}
if (ustAnzeige) ustAnzeige.textContent = parseFloat(ust.value).toFixed(2).replace('.', ',');
vorlageUpdateBruttoGesamt();
});
function vorlageUpdateBruttoGesamt() {
const modal = document.getElementById('vorlage-modal');
if (!modal) return;
const bruttoFelder = modal.querySelectorAll('.vorlage-brutto');
const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(String(el.value).replace(',', '.')) || 0), 0);
const anzeige = modal.querySelector('[data-feldname="brutto_gesamt"]');
if (anzeige) anzeige.textContent = summe.toFixed(2).replace('.', ',') + ' €';
}
// Zahldatum-Feld Änderung → buchungs_datum-Hidden synchronisieren
document.addEventListener('change', function(e) {
if (!e.target.matches('#vorlage-modal #vorlage-zahldatum')) return;
const hidden = document.getElementById('vorlage-buchungs-datum');
if (hidden) hidden.value = e.target.value;
});