Fix: Dublettenprüfung verwendet nur (buchungstag, wertstellung, wert) ohne buchungstext, da Vorlagenänderungen sonst zu falschen Duplikaten führen.
311 lines
15 KiB
PHP
311 lines
15 KiB
PHP
<?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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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>
|