Steuerkram/src/Views/bank/upload.php
fds2610-bot aa9ccd1207 Feature: Bank-Import mit konfigurierbaren Formaten, Buchungstext-Templates und Dublettenprüfung
Fix: Dublettenprüfung verwendet nur (buchungstag, wertstellung, wert) ohne buchungstext,
da Vorlagenänderungen sonst zu falschen Duplikaten führen.
2026-05-17 13:47:16 +02:00

311 lines
15 KiB
PHP
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.

<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card">
<div class="card-body">
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
<!-- Drag & Drop Zone -->
<div id="bank-dnd-zone" style="
border:2px dashed var(--color-border);
border-radius:var(--radius);
background:var(--color-surface);
padding:3rem 2rem;
text-align:center;
margin-bottom:1.5rem;
cursor:pointer;
transition:border-color .15s,background .15s;
position:relative;
">
<input type="file" id="bank-dnd-input" name="bank_file"
accept=".csv,.txt,.xlsx"
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
<div style="font-size:2rem;line-height:1;margin-bottom:.75rem;pointer-events:none">🏦</div>
<div style="font-weight:600;font-size:1.1rem;pointer-events:none">Bank-Buchungen hierher ziehen oder klicken</div>
<div class="text-muted text-sm" style="margin-top:.4rem;pointer-events:none">CSV · TXT · XLSX</div>
<div id="bank-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
</div>
<!-- Format-Erkennung: Ergebnis-Bereich -->
<div id="fmt-result" style="display:none">
<!-- Auto-erkannt -->
<div id="fmt-matched" style="display:none;padding:.75rem 1rem;border-radius:var(--radius);background:#f0fdf4;border:1px solid #86efac;margin-bottom:1.25rem">
<div style="font-weight:600;color:#15803d">Format erkannt</div>
<div id="fmt-matched-name" style="color:#166534;margin-top:.2rem"></div>
</div>
<!-- Nicht eindeutig erkannt: Auswahl + Vorschau -->
<div id="fmt-picker" style="display:none">
<div style="padding:.6rem .9rem;border-radius:var(--radius);background:#fffbeb;border:1px solid #fcd34d;margin-bottom:1.25rem;font-size:.875rem;color:#92400e">
Das Format konnte nicht eindeutig erkannt werden. Bitte wählen Sie das passende Format:
</div>
<div class="form-group" style="margin-bottom:1.25rem">
<label for="fmt-select">Import-Format</label>
<select id="fmt-select" style="width:100%">
<?php foreach ($formats as $fmt): ?>
<option value="<?= (int)$fmt['id'] ?>"><?= $h($fmt['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Vorschau-Panel -->
<div id="fmt-preview-panel" style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius);padding:1rem;margin-bottom:1.25rem">
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.75rem">
Datei-Vorschau
</div>
<!-- Überschriften-Zeile -->
<div style="margin-bottom:.75rem">
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Überschriften-Zeile</div>
<div id="preview-header" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
</div>
<!-- Erste Werte-Zeile -->
<div style="margin-bottom:1rem">
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Erste Werte-Zeile</div>
<div id="preview-firstrow" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
</div>
<!-- Import-Zuordnung -->
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.5rem">
Importiert als
</div>
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<tr>
<td style="padding:.3rem .4rem .3rem 0;width:130px;color:var(--color-muted)">Wertstellung</td>
<td id="map-wertstellung" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
<tr>
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Buchungstext</td>
<td id="map-buchungstext" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
<tr>
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Wert (€)</td>
<td id="map-wert" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div><!-- /fmt-result -->
<!-- Verstecktes Format-ID Feld -->
<input type="hidden" id="format_id" name="format_id" value="">
<!-- Erkennung läuft -->
<div id="fmt-detecting" style="display:none;color:var(--color-muted);font-size:.875rem;margin-bottom:1rem">
Format wird erkannt…
</div>
<div style="display:flex;gap:.75rem;margin-top:.5rem">
<button type="submit" class="btn btn-primary" id="bank-upload-btn" disabled>Importieren</button>
<a href="/bank" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<script>
(function () {
const zone = document.getElementById('bank-dnd-zone');
const input = document.getElementById('bank-dnd-input');
const status = document.getElementById('bank-dnd-status');
const btn = document.getElementById('bank-upload-btn');
const fmtResult = document.getElementById('fmt-result');
const fmtMatched = document.getElementById('fmt-matched');
const fmtPicker = document.getElementById('fmt-picker');
const fmtDetect = document.getElementById('fmt-detecting');
const fmtSelect = document.getElementById('fmt-select');
const formatId = document.getElementById('format_id');
let formatsData = {};
let suggestedFormat = null;
function setActive(on) {
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
}
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderHeaderRow(headerRow, firstData) {
if (!headerRow || headerRow.length === 0) {
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
// Keine Überschriften – zeige Spaltenindizes
return firstData.map((_, i) => `<span style="opacity:.5">Sp.${i}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
}
return headerRow.map((h, i) => `<span title="Spalte ${i}">${esc(h)}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
}
function renderDataRow(firstData, header) {
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
return firstData.map((v, i) => {
const lbl = header && header[i] ? `title="Spalte ${i}: ${esc(header[i])}"` : `title="Spalte ${i}"`;
return `<span ${lbl}>${esc(v)}</span>`;
}).join(' <span style="color:var(--color-border)">|</span> ');
}
function resolveTemplate(tpl, header, firstData) {
if (!tpl) return null;
return tpl.replace(/\{([^}]+)\}/g, (_, key) => {
const idx = header ? header.indexOf(key) : -1;
return idx >= 0 ? (firstData[idx] || '') : `{${key}}`;
});
}
function getMappedValue(fmt, header, firstData, col) {
const idx = parseInt(fmt[col], 10);
if (isNaN(idx) || !firstData || idx >= firstData.length) return '–';
return firstData[idx] || '–';
}
function updatePreview(fmtId) {
const data = formatsData[fmtId];
if (!data) return;
const fmt = data.format;
const header = data.header_row;
const firstData = data.first_data;
document.getElementById('preview-header').innerHTML = renderHeaderRow(header, firstData);
document.getElementById('preview-firstrow').innerHTML = renderDataRow(firstData, header);
// Wertstellung
const wsIdx = parseInt(fmt.col_wertstellung, 10);
const wsVal = firstData && firstData[wsIdx] !== undefined ? firstData[wsIdx] : '–';
document.getElementById('map-wertstellung').textContent = `${wsVal} (Spalte ${wsIdx})`;
// Buchungstext – Template bevorzugen
const wertVal = firstData && firstData[parseInt(fmt.col_wert, 10)] ? firstData[parseInt(fmt.col_wert, 10)] : '0';
const isDebit = parseFloat(wertVal.replace(',', '.').replace(/[^\d.\-]/g, '')) < 0;
const tpl = isDebit ? (fmt.buchungstext_template_debit || '') : (fmt.buchungstext_template_credit || '');
let btVal;
if (tpl) {
btVal = resolveTemplate(tpl, header, firstData);
document.getElementById('map-buchungstext').textContent = `${btVal} (Template)`;
} else {
const btIdx = parseInt(fmt.col_buchungstext, 10);
btVal = firstData && firstData[btIdx] !== undefined ? firstData[btIdx] : '–';
document.getElementById('map-buchungstext').textContent = `${btVal} (Spalte ${btIdx})`;
}
// Wert
const wIdx = parseInt(fmt.col_wert, 10);
const wVal = firstData && firstData[wIdx] !== undefined ? firstData[wIdx] : '–';
document.getElementById('map-wert').textContent = `${wVal} (Spalte ${wIdx})`;
}
async function detectFormat(file) {
const fd = new FormData();
fd.append('bank_file', file);
fmtDetect.style.display = 'block';
fmtResult.style.display = 'none';
btn.disabled = true;
let json;
try {
const res = await fetch('/bank/detect-format', { method: 'POST', body: fd });
json = await res.json();
} catch (_) {
json = { error: 'Verbindungsfehler bei der Format-Erkennung.' };
}
fmtDetect.style.display = 'none';
if (json.error) {
status.innerHTML = `<span style="color:var(--color-danger)">${esc(json.error)}</span>`;
return;
}
formatsData = json.formats_data || {};
suggestedFormat = json.suggested_format || null;
fmtResult.style.display = 'block';
if (json.matched_format_id !== null && json.matched_format_id !== undefined) {
// Format eindeutig erkannt
const matched = formatsData[json.matched_format_id];
const name = matched ? matched.format.name : 'Unbekannt';
document.getElementById('fmt-matched-name').textContent = name;
formatId.value = json.matched_format_id;
fmtMatched.style.display = 'block';
fmtPicker.style.display = 'none';
btn.disabled = false;
} else {
// Nicht eindeutig – Auswahl zeigen
fmtMatched.style.display = 'none';
fmtPicker.style.display = 'block';
// Passendes Format vorauswählen (erstes mit is_match, sonst erstes)
let preselect = null;
for (const [id, d] of Object.entries(formatsData)) {
if (d.is_match) { preselect = id; break; }
}
if (!preselect && Object.keys(formatsData).length > 0) {
preselect = Object.keys(formatsData)[0];
}
if (preselect) {
fmtSelect.value = preselect;
formatId.value = preselect;
updatePreview(preselect);
}
btn.disabled = false;
}
}
function onFile(file) {
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!['csv', 'txt', 'xlsx'].includes(ext)) {
status.innerHTML = '<span style="color:var(--color-danger)">Nicht unterstütztes Format. Bitte CSV, TXT oder XLSX verwenden.</span>';
btn.disabled = true;
fmtResult.style.display = 'none';
return;
}
status.innerHTML = `<span style="color:var(--color-success)">✓ ${esc(file.name)} (${(file.size / 1024).toFixed(1)} KB)</span>`;
detectFormat(file);
}
fmtSelect.addEventListener('change', () => {
formatId.value = fmtSelect.value;
updatePreview(fmtSelect.value);
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
zone.addEventListener('drop', (e) => {
e.preventDefault();
setActive(false);
const file = e.dataTransfer.files[0];
if (file) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
onFile(file);
}
});
input.addEventListener('change', () => onFile(input.files[0]));
// "Formate bearbeiten"-Link: Suggestion in Session speichern, dann navigieren
document.addEventListener('click', async (e) => {
const link = e.target.closest('a[href="/settings/bankimport"]');
if (!link || !suggestedFormat) return;
e.preventDefault();
try {
await fetch('/settings/bankimport/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(suggestedFormat),
});
} catch (_) {}
window.location.href = '/settings/bankimport';
});
})();
</script>