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.
This commit is contained in:
fds2610-bot 2026-05-17 13:47:16 +02:00
parent f78353626d
commit aa9ccd1207
28 changed files with 2125 additions and 55 deletions

View file

@ -2,6 +2,34 @@
---
## 2026-05-17 – Feature: Automatischer Format-Vorschlag beim Bank-Import
- **Format-Vorschlag**: Wenn eine hochgeladene CSV keinem bekannten Format eindeutig entspricht, analysiert `BankImportParser::suggestFormat()` die Datei automatisch (Headerzeilen-Erkennung bei skip_rows 0–10, Delimiter-Erkennung, Keyword-basiertes Spalten-Mapping)
- **Session-gestützter Vorschlags-Flow**: Klick auf „Formate bearbeiten" speichert den Vorschlag via AJAX in der Session und öffnet die Einstellungsseite
- **Vorschlags-Card**: Auf `/settings/bankimport` erscheint der Vorschlag oberhalb bestehender Formate als ausfüllbares Formular mit vorausgefüllten Feldern (skip_rows, Delimiter, Spalten-Mapping mit erkannten Headernamen)
- **Explizites Speichern**: Neues Format wird erst nach Klick auf „Speichern" in der DB angelegt (POST `/settings/bankimport/create`); Verwerfen löscht die Session-Suggestion
- Neue Routen: `POST /settings/bankimport/suggest`, `POST /settings/bankimport/create`, `POST /settings/bankimport/dismiss-suggestion`
- Neues Model: `BankImportFormat::create()`; neuer Test 35
---
## 2026-05-17 – Feature: Automatische Format-Erkennung beim Bank-Import
- **Format-Erkennung**: Hochgeladene Datei wird automatisch gegen alle gespeicherten Formate geprüft; bei eindeutigem Match wird sofort importiert
- **Vorschau-Panel**: Wenn kein Format passt, zeigt die Upload-Seite Überschriften-Zeile, erste Werte-Zeile und die Spalten-Zuordnung (Wertstellung, Buchungstext, Wert) für das gewählte Format an; wechselt live beim Umschalten der Formatauswahl
- **„Formate bearbeiten"-Button** oben rechts verlinkt direkt auf die Einstellungsseite für Bankimport-Formate
- Neuer AJAX-Endpunkt `POST /bank/detect-format` in `BankController`; neue Methoden `peekRows()` und `isFormatMatch()` in `BankImportParser`
---
## 2026-05-17 – Feature: DKB-Importformat für Bankbuchungen
- **Neues Format „DKB Bank"**: 4 Skip-Zeilen, Semikolon-getrennt, Anführungszeichen-eingeschlossen
- Buchungstext wird konditionell zusammengesetzt: bei negativem Betrag aus Zahlungsempfänger + Verwendungszweck, sonst aus Zahlungspflichtiger + Verwendungszweck
- Migration 054 ergänzt drei neue Nullable-Spalten in `bank_import_formats` (`col_zahlungspflichtiger`, `col_zahlungsempfaenger`, `col_verwendungszweck`)
---
## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz
- **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input

View file

@ -65,6 +65,10 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
## Bank buchen
- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank)
- **Unterstützte Importformate**: Comdirect, DKB Bank
- **Automatische Format-Erkennung**: beim Datei-Upload wird das Format anhand von Datumsfeldern und Betragsspalten automatisch erkannt; bei eindeutigem Treffer wird der Import direkt gestartet
- **Format-Vorschau**: bei unbekanntem Format zeigt die Upload-Seite Überschriften- und erste Werte-Zeile sowie die konkrete Spalten-Zuordnung für das gewählte Format an (wechselt live)
- **Automatischer Format-Vorschlag**: wenn kein gespeichertes Format passt, analysiert das System die CSV automatisch (Headerzeilen-Erkennung, Keyword-Matching); über „Formate bearbeiten" öffnet sich die Einstellungsseite mit einem vorausgefüllten Vorschlags-Formular – das neue Format wird erst nach explizitem Klick auf „Speichern" angelegt
- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert
- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst
- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden

View file

@ -0,0 +1,20 @@
-- DKB-Importformat: drei neue Spalten für konditionelle Buchungstext-Zusammensetzung
ALTER TABLE bank_import_formats
ADD COLUMN IF NOT EXISTS col_zahlungspflichtiger INT NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS col_zahlungsempfaenger INT NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS col_verwendungszweck INT NULL DEFAULT NULL;
-- DKB Bank: 4 Skip-Zeilen, dann Headerzeile
-- "Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";
-- "Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"
INSERT INTO bank_import_formats
(name, skip_rows, has_header_row, delimiter, enclosure,
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
date_format, decimal_separator, thousands_separator)
VALUES
('DKB Bank', 4, 1, ';', '"',
0, 1, 6, 5, 8,
3, 4, 5,
'd.m.Y', ',', '.')
ON DUPLICATE KEY UPDATE name = name;

View file

@ -0,0 +1,13 @@
-- Buchungstext-Templates: konfigurierbare Zusammensetzung per {SpaltenüberschriftName}
ALTER TABLE bank_import_formats
ADD COLUMN IF NOT EXISTS buchungstext_template_debit VARCHAR(500) NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS buchungstext_template_credit VARCHAR(500) NULL DEFAULT NULL;
-- DKB Bank auf Templates umstellen; DKB-spezifische Spaltenindices werden nicht mehr benötigt
UPDATE bank_import_formats SET
buchungstext_template_debit = 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}',
buchungstext_template_credit = 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}',
col_zahlungspflichtiger = NULL,
col_zahlungsempfaenger = NULL,
col_verwendungszweck = NULL
WHERE name = 'DKB Bank';

View file

@ -0,0 +1,5 @@
-- Comdirect: Buchungstext-Templates setzen (CSV-Spalte via Platzhalter {Buchungstext})
UPDATE bank_import_formats SET
buchungstext_template_debit = '{Buchungstext}',
buchungstext_template_credit = '{Buchungstext}'
WHERE name = 'Comdirect';

View file

@ -0,0 +1,5 @@
ALTER TABLE bank_import_formats
ADD COLUMN IF NOT EXISTS kuerzel VARCHAR(10) NOT NULL DEFAULT '' AFTER name;
UPDATE bank_import_formats SET kuerzel = 'C' WHERE name = 'Comdirect';
UPDATE bank_import_formats SET kuerzel = 'DKB' WHERE name = 'DKB Bank';

View file

@ -0,0 +1,13 @@
-- DKB exportiert Datumsfelder zweistellig (dd.mm.yy), nicht vierstellig
UPDATE bank_import_formats
SET date_format = 'd.m.y'
WHERE name = 'DKB Bank';
-- Bereits importierte DKB-Buchungen mit Jahr 0026 auf 2026 korrigieren
UPDATE bank_transactions bt
JOIN bank_import_batches bib ON bib.id = bt.batch_id
JOIN bank_import_formats bif ON bif.id = bib.format_id
SET bt.wertstellung = DATE_ADD(bt.wertstellung, INTERVAL 2000 YEAR),
bt.buchungstag = DATE_ADD(bt.buchungstag, INTERVAL 2000 YEAR)
WHERE bif.name = 'DKB Bank'
AND YEAR(bt.wertstellung) < 100;

View file

@ -0,0 +1,5 @@
ALTER TABLE bank_import_formats
ADD COLUMN IF NOT EXISTS farbe VARCHAR(7) NOT NULL DEFAULT '#6366f1' AFTER kuerzel;
UPDATE bank_import_formats SET farbe = '#f59e0b' WHERE name = 'Comdirect';
UPDATE bank_import_formats SET farbe = '#dc2626' WHERE name = 'DKB Bank';

View file

@ -0,0 +1,9 @@
-- Comdirect CSV hat eine Leerzeile zwischen Metadaten und Headerzeile.
-- Mit skip_rows=3 wurde die Leerzeile als Header gelesen → headerMap leer
-- → Template {Buchungstext} konnte nicht auflösen → leerer buchungstext.
-- Fix: skip_rows auf 4 setzen und Templates entfernen (col_buchungstext=3 genügt direkt).
UPDATE bank_import_formats
SET skip_rows = 4,
buchungstext_template_debit = NULL,
buchungstext_template_credit = NULL
WHERE name = 'Comdirect';

View file

@ -0,0 +1,7 @@
-- format_id in bank_import_batches nullable machen.
-- Beim Löschen eines Formats werden vorhandene Batches behalten; format_id wird NULL gesetzt.
ALTER TABLE bank_import_batches DROP FOREIGN KEY bank_import_batches_ibfk_1;
ALTER TABLE bank_import_batches MODIFY format_id INT UNSIGNED NULL;
ALTER TABLE bank_import_batches
ADD CONSTRAINT fk_bib_format_id
FOREIGN KEY (format_id) REFERENCES bank_import_formats(id) ON DELETE SET NULL;

View file

@ -68,6 +68,7 @@ $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController(
$router->get('/bank', fn($p) => (new BankController())->index($p));
$router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p));
$router->post('/bank/upload', fn($p) => (new BankController())->upload($p));
$router->post('/bank/detect-format', fn($p) => (new BankController())->detectFormat($p));
$router->post('/bank/upload/confirm', fn($p) => (new BankController())->uploadConfirm($p));
$router->get('/bank/{id}', fn($p) => (new BankController())->show($p));
$router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($p));
@ -150,6 +151,12 @@ $router->post('/settings/mail-template', fn($p) => (new SettingsController())->m
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
$router->get('/settings/bankimport', fn($p) => (new SettingsController())->bankImport($p));
$router->post('/settings/bankimport/suggest', fn($p) => (new SettingsController())->storeSuggestion($p));
$router->post('/settings/bankimport/create', fn($p) => (new SettingsController())->createBankImportFormat($p));
$router->post('/settings/bankimport/dismiss-suggestion', fn($p) => (new SettingsController())->dismissSuggestion($p));
$router->post('/settings/bankimport/{id}/field', fn($p) => (new SettingsController())->updateBankImportField($p));
$router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($p));
// ── Login / Logout ────────────────────────────────────────────
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));

View file

@ -316,6 +316,17 @@ td { padding: .65rem 1rem; vertical-align: middle; }
.badge-gray { background: #f1f5f9; color: #475569; }
.badge-purple { background: #ede9fe; color: #6d28d9; }
.kuerzel-badge {
display: inline-block;
padding: .15rem .6rem;
border-radius: 999px;
font-size: .72rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
vertical-align: middle;
}
/* ── Toolbar / Filter bar ────────────────────────────────────── */
.toolbar {
display: flex;

View file

@ -14,6 +14,10 @@ class AccountController extends BaseController
public function dashboard(array $params): void
{
$year = (int)date('Y');
$month = (int)date('m');
$prevMonth = $month === 1 ? 12 : $month - 1;
$prevYear = $month === 1 ? $year - 1 : $year;
$monthNames = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
// Quick stats
$stats = [];
@ -28,6 +32,20 @@ class AccountController extends BaseController
);
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
$r = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
[$year, $month]
);
$stats['umsatz_monat'] = (float)($r['total'] ?? 0);
$r = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
[$prevYear, $prevMonth]
);
$stats['umsatz_vormonat'] = (float)($r['total'] ?? 0);
// Ausgaben
$ausgaben = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
@ -37,8 +55,27 @@ class AccountController extends BaseController
[$year]
);
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
$r = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
[$year, $month]
);
$stats['ausgaben_monat'] = (float)($r['total'] ?? 0);
$r = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings
WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?',
[$prevYear, $prevMonth]
);
$stats['ausgaben_vormonat'] = (float)($r['total'] ?? 0);
$stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben'];
$stats['gewinn_monat'] = $stats['umsatz_monat'] - $stats['ausgaben_monat'];
$stats['gewinn_vormonat'] = $stats['umsatz_vormonat'] - $stats['ausgaben_vormonat'];
$stats['year'] = $year;
$stats['month_label'] = $monthNames[$month - 1];
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
// Offene Rechnungen
$offen = \Steuerkram\Database::fetchOne(

View file

@ -26,6 +26,7 @@ class BankController extends BaseController
$page = max(1, (int)($_GET['page'] ?? 1));
$result = BankTransaction::all($filter, $page);
$formats = BankImportFormat::all();
$view = new View();
$view->render('layout/base.php', [
@ -36,6 +37,7 @@ class BankController extends BaseController
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
'formats' => $formats,
]),
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
]);
@ -47,6 +49,7 @@ class BankController extends BaseController
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bank-Buchungen importieren',
'topbarActions' => '<a href="/settings/bankimport" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
'content' => $this->renderView('bank/upload', [
'formats' => $formats,
'errors' => [],
@ -54,6 +57,62 @@ class BankController extends BaseController
]);
}
public function detectFormat(array $params): void
{
header('Content-Type: application/json');
$file = $_FILES['bank_file'] ?? null;
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload fehlgeschlagen.']);
return;
}
$origName = $file['name'] ?? 'upload';
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXT, true)) {
echo json_encode(['error' => 'Nicht erlaubtes Dateiformat.']);
return;
}
$formats = BankImportFormat::all();
$parser = new BankImportParser();
$formatsData = [];
$matchedIds = [];
foreach ($formats as $format) {
$id = (int)$format['id'];
$peek = $parser->peekRows($file['tmp_name'], $ext, $format);
$isMatch = $parser->isFormatMatch($file['tmp_name'], $ext, $format);
if ($isMatch) {
$matchedIds[] = $id;
}
$formatsData[$id] = [
'format' => $format,
'header_row' => $peek['header'],
'first_data' => $peek['first_data'],
'is_match' => $isMatch,
];
}
$matchedFormatId = count($matchedIds) === 1 ? $matchedIds[0] : null;
$suggestedFormat = null;
if ($matchedFormatId === null) {
$suggestedFormat = $parser->suggestFormat($file['tmp_name'], $ext);
}
echo json_encode([
'matched_format_id' => $matchedFormatId,
'formats_data' => $formatsData,
'filename' => $origName,
'suggested_format' => $suggestedFormat,
], JSON_UNESCAPED_UNICODE);
}
public function upload(array $params): void
{
$formatId = (int)($_POST['format_id'] ?? 0);

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\View;
@ -138,4 +139,250 @@ class SettingsController extends BaseController
View::redirect('/settings', 'Logo gelöscht.');
}
public function bankImport(array $params): void
{
$formats = BankImportFormat::all();
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bankimport-Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
]),
]);
}
public function deleteBankImportFormat(array $params): void
{
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankImportFormat::find($id);
if (!$fmt) {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
}
BankImportFormat::delete($id);
echo json_encode(['ok' => true]);
}
public function storeSuggestion(array $params): void
{
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
if (is_array($data)) {
$_SESSION['bank_import_suggestion'] = $data;
echo json_encode(['ok' => true]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Keine Daten.']);
}
}
public function dismissSuggestion(array $params): void
{
header('Content-Type: application/json');
unset($_SESSION['bank_import_suggestion']);
echo json_encode(['ok' => true]);
}
public function createBankImportFormat(array $params): void
{
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
$name = trim($_POST['name'] ?? '');
$kuerzel = trim($_POST['kuerzel'] ?? '');
$farbe = $_POST['farbe'] ?? '#6366f1';
$skipRows = (int)($_POST['skip_rows'] ?? 0);
$hasHeader = in_array($_POST['has_header_row'] ?? '1', ['0', '1'], true)
? (int)$_POST['has_header_row'] : 1;
$delimiter = $_POST['delimiter'] ?? ';';
$enclosure = $_POST['enclosure'] ?? '"';
$dateFmt = $_POST['date_format'] ?? 'd.m.Y';
$decSep = $_POST['decimal_separator'] ?? ',';
$thouSep = $_POST['thousands_separator'] ?? '.';
$errors = [];
if ($name === '' || mb_strlen($name) > 100) {
$errors[] = 'Name darf nicht leer sein (max. 100 Zeichen).';
}
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
$errors[] = 'Kürzel darf nicht leer sein (max. 10 Zeichen).';
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $farbe)) {
$farbe = '#6366f1';
}
if ($skipRows < 0 || $skipRows > 99) {
$skipRows = 0;
}
if (mb_strlen($delimiter) !== 1) {
$errors[] = 'Trennzeichen muss genau ein Zeichen sein.';
}
if (mb_strlen($enclosure) !== 1) {
$errors[] = 'Anführungszeichen muss genau ein Zeichen sein.';
}
if (!in_array($dateFmt, $dateFormats, true)) {
$dateFmt = 'd.m.Y';
}
if (mb_strlen($decSep) !== 1) {
$errors[] = 'Dezimaltrennzeichen muss genau ein Zeichen sein.';
}
if (mb_strlen($thouSep) > 1) {
$thouSep = '';
}
if (!empty($errors)) {
// Bei Validierungsfehler zurück mit Fehlermeldung
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$formats = BankImportFormat::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bankimport-Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
'errors' => $errors,
]),
]);
return;
}
$tplDebit = mb_substr(trim($_POST['buchungstext_template_debit'] ?? ''), 0, 500);
$tplCredit = mb_substr(trim($_POST['buchungstext_template_credit'] ?? ''), 0, 500);
$nullInt = function (string $key): ?int {
$v = $_POST[$key] ?? '';
if ($v === '') return null;
return (int)$v;
};
$intCol = function (string $key, int $default = 0): int {
$v = $_POST[$key] ?? '';
return $v !== '' ? (int)$v : $default;
};
$id = BankImportFormat::create([
'name' => $name,
'kuerzel' => $kuerzel,
'farbe' => $farbe,
'skip_rows' => $skipRows,
'has_header_row' => $hasHeader,
'delimiter' => $delimiter,
'enclosure' => $enclosure,
'date_format' => $dateFmt,
'decimal_separator' => $decSep,
'thousands_separator' => $thouSep,
'col_buchungstag' => $intCol('col_buchungstag', 0),
'col_wertstellung' => $intCol('col_wertstellung', 1),
'col_vorgang' => $intCol('col_vorgang', 2),
'col_buchungstext' => 0,
'col_wert' => $intCol('col_wert', 4),
'col_zahlungspflichtiger' => $nullInt('col_zahlungspflichtiger'),
'col_zahlungsempfaenger' => $nullInt('col_zahlungsempfaenger'),
'col_verwendungszweck' => $nullInt('col_verwendungszweck'),
'buchungstext_template_debit' => $tplDebit !== '' ? $tplDebit : null,
'buchungstext_template_credit' => $tplCredit !== '' ? $tplCredit : null,
]);
unset($_SESSION['bank_import_suggestion']);
header('Location: /settings/bankimport#format-' . $id);
exit;
}
public function updateBankImportField(array $params): void
{
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankImportFormat::find($id);
if (!$fmt) {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
}
$field = $_POST['field'] ?? '';
$raw = $_POST['value'] ?? '';
$intCols = ['col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert'];
$nullIntCols = ['col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck'];
$charCols = ['delimiter', 'enclosure', 'decimal_separator'];
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
$tplCols = ['buchungstext_template_debit', 'buchungstext_template_credit'];
[$ok, $value, $err] = match (true) {
$field === 'name'
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 100)
? [true, trim($raw), '']
: [false, null, 'Name darf nicht leer sein (max. 100 Zeichen).'],
$field === 'kuerzel'
=> (trim($raw) !== '' && mb_strlen(trim($raw)) <= 10)
? [true, trim($raw), '']
: [false, null, 'Kürzel darf nicht leer sein (max. 10 Zeichen).'],
$field === 'farbe'
=> (bool)preg_match('/^#[0-9a-fA-F]{6}$/', $raw)
? [true, $raw, '']
: [false, null, 'Ungültige Farbe (Format: #RRGGBB).'],
$field === 'skip_rows'
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
? [true, (int)$raw, '']
: [false, null, 'Übersprungene Zeilen: ganze Zahl 0–99 erforderlich.'],
$field === 'has_header_row'
=> in_array($raw, ['0', '1'], true)
? [true, (int)$raw, '']
: [false, null, 'Ungültiger Wert.'],
in_array($field, $charCols, true)
=> (mb_strlen($raw) === 1)
? [true, $raw, '']
: [false, null, 'Genau ein Zeichen erforderlich.'],
$field === 'thousands_separator'
=> (mb_strlen($raw) <= 1)
? [true, $raw, '']
: [false, null, 'Maximal ein Zeichen.'],
in_array($field, $intCols, true)
=> (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99)
? [true, (int)$raw, '']
: [false, null, 'Gültige Spaltennummer (0–99) erforderlich.'],
in_array($field, $nullIntCols, true)
=> ($raw === '' || $raw === null)
? [true, null, '']
: (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99
? [true, (int)$raw, '']
: [false, null, 'Gültige Spaltennummer (0–99) oder leer für nicht gesetzt.']),
$field === 'date_format'
=> in_array($raw, $dateFormats, true)
? [true, $raw, '']
: [false, null, 'Unbekanntes Datumsformat.'],
in_array($field, $tplCols, true)
=> (mb_strlen($raw) <= 500)
? [true, $raw === '' ? null : $raw, '']
: [false, null, 'Template zu lang (max. 500 Zeichen).'],
default => [false, null, 'Unbekanntes Feld.'],
};
if (!$ok) {
http_response_code(422);
echo json_encode(['error' => $err]);
return;
}
BankImportFormat::updateField($id, $field, $value);
echo json_encode(['ok' => true]);
}
}

View file

@ -18,4 +18,53 @@ class BankImportFormat
$row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]);
return $row ?: null;
}
public static function updateField(int $id, string $field, mixed $value): void
{
Database::execute(
"UPDATE bank_import_formats SET `{$field}` = ? WHERE id = ?",
[$value, $id]
);
}
public static function delete(int $id): void
{
Database::execute('DELETE FROM bank_import_formats WHERE id = ?', [$id]);
}
public static function create(array $data): int
{
Database::execute(
'INSERT INTO bank_import_formats
(name, kuerzel, farbe, skip_rows, has_header_row, delimiter, enclosure,
date_format, decimal_separator, thousands_separator,
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
buchungstext_template_debit, buchungstext_template_credit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$data['name'],
$data['kuerzel'],
$data['farbe'],
$data['skip_rows'],
$data['has_header_row'],
$data['delimiter'],
$data['enclosure'],
$data['date_format'],
$data['decimal_separator'],
$data['thousands_separator'],
$data['col_buchungstag'],
$data['col_wertstellung'],
$data['col_vorgang'],
$data['col_buchungstext'],
$data['col_wert'],
$data['col_zahlungspflichtiger'],
$data['col_zahlungsempfaenger'],
$data['col_verwendungszweck'],
$data['buchungstext_template_debit'] ?? null,
$data['buchungstext_template_credit'] ?? null,
]
);
return (int)Database::lastInsertId();
}
}

View file

@ -42,22 +42,39 @@ class BankTransaction
$params[] = $s;
$params[] = $s;
}
if (!empty($filter['format_id'])) {
$where[] = 'bib.format_id = ?';
$params[] = (int)$filter['format_id'];
}
$sortMap = [
'wertstellung' => 'bt.wertstellung',
'buchungstag' => 'bt.buchungstag',
'betrag' => 'bt.wert',
'buchungstext' => 'bt.buchungstext',
'status' => 'bt.status',
];
$sortCol = $sortMap[$filter['sort'] ?? ''] ?? 'bt.wertstellung';
$sortDir = ($filter['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$whereStr = implode(' AND ', $where);
$offset = ($page - 1) * $perPage;
$total = (int)(Database::fetchOne(
"SELECT COUNT(*) AS n FROM bank_transactions bt WHERE $whereStr",
"SELECT COUNT(*) AS n
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
WHERE $whereStr",
$params
)['n'] ?? 0);
$rows = Database::fetchAll(
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name,
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe,
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
b.beleg_nr
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
JOIN bank_import_formats bif ON bib.format_id = bif.id
LEFT JOIN bank_import_formats bif ON bib.format_id = bif.id
LEFT JOIN (
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
FROM bank_transaction_belege
@ -65,7 +82,7 @@ class BankTransaction
) btb_agg ON btb_agg.bank_tx_id = bt.id
LEFT JOIN belege b ON b.id = btb_agg.beleg_id
WHERE $whereStr
ORDER BY bt.wertstellung DESC, bt.id DESC
ORDER BY $sortCol $sortDir, bt.wertstellung $sortDir, bt.buchungstag $sortDir, bt.id $sortDir
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
@ -80,7 +97,7 @@ class BankTransaction
public static function find(int $id): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
JOIN bank_import_formats bif ON bib.format_id = bif.id
@ -184,10 +201,11 @@ class BankTransaction
public static function findByBelegId(int $belegId): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, btb.betrag AS alloc_betrag
'SELECT bt.*, bib.filename AS batch_filename, bif.kuerzel, bif.farbe, btb.betrag AS alloc_betrag
FROM bank_transaction_belege btb
JOIN bank_transactions bt ON bt.id = btb.bank_tx_id
JOIN bank_import_batches bib ON bib.id = bt.batch_id
JOIN bank_import_formats bif ON bif.id = bib.format_id
WHERE btb.beleg_id = ?
LIMIT 1',
[$belegId]
@ -213,7 +231,7 @@ class BankTransaction
}
$existing = Database::fetchAll(
"SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert, LEFT(buchungstext,200) AS btext
"SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert
FROM bank_transactions
WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)",
$params
@ -221,7 +239,7 @@ class BankTransaction
$lookup = [];
foreach ($existing as $e) {
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert']] = true;
}
$new = [];
@ -229,8 +247,7 @@ class BankTransaction
foreach ($rows as $row) {
$key = $row['buchungstag'] . '|'
. $row['wertstellung'] . '|'
. (string)$row['wert'] . '|'
. mb_substr($row['buchungstext'], 0, 200);
. (string)$row['wert'];
if (isset($lookup[$key])) {
$dup[] = $row;
} else {

View file

@ -22,10 +22,16 @@ class BankImportParser
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
}
// Übersprungene Zeilen und optionale Headerzeile entfernen
// Übersprungene Zeilen entfernen
$skipRows = (int)$format['skip_rows'];
$lines = array_slice($lines, $skipRows);
if (!empty($format['has_header_row'])) {
// Headerzeile auslesen (vor dem Entfernen), um {Überschrift}-Templates aufzulösen
$headerMap = [];
if (!empty($format['has_header_row']) && isset($lines[0])) {
foreach ($lines[0] as $idx => $header) {
$headerMap[trim($header)] = $idx;
}
$lines = array_slice($lines, 1);
}
@ -35,6 +41,9 @@ class BankImportParser
$colBuchungstext = (int)$format['col_buchungstext'];
$colWert = (int)$format['col_wert'];
$tplDebit = !empty($format['buchungstext_template_debit']) ? $format['buchungstext_template_debit'] : null;
$tplCredit = !empty($format['buchungstext_template_credit']) ? $format['buchungstext_template_credit'] : null;
$result = [];
foreach ($lines as $i => $cells) {
// Leerzeilen überspringen
@ -55,11 +64,21 @@ class BankImportParser
continue;
}
$activeTpl = $wert < 0 ? $tplDebit : $tplCredit;
if ($activeTpl !== null) {
$buchungstext = preg_replace_callback('/\{([^}]+)\}/', function ($m) use ($headerMap, $cells) {
$idx = $headerMap[$m[1]] ?? null;
return $idx !== null ? trim($cells[$idx] ?? '') : '';
}, $activeTpl);
} else {
$buchungstext = trim($cells[$colBuchungstext] ?? '');
}
$result[] = [
'buchungstag' => $buchungstag,
'wertstellung' => $wertstellung,
'vorgang' => trim($cells[$colVorgang] ?? ''),
'buchungstext' => trim($cells[$colBuchungstext] ?? ''),
'buchungstext' => $buchungstext,
'wert' => $wert,
'raw_line' => implode($format['delimiter'], $cells),
];
@ -222,6 +241,175 @@ class BankImportParser
return [$col - 1, (int)($m[2] ?? 1) - 1];
}
/**
* Liest Überschriften- und erste Datenzeile aus einer Datei gemäß Format-Konfiguration.
* Gibt ['header' => [...], 'first_data' => [...]] zurück.
*/
public function peekRows(string $filePath, string $ext, array $format): array
{
try {
$fmtExt = strtolower($ext);
if ($fmtExt === 'xlsx') {
$lines = $this->readXlsx($filePath);
} else {
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
}
$skipRows = (int)$format['skip_rows'];
$lines = array_slice($lines, $skipRows);
// Führende Leerzeilen überspringen
while (!empty($lines) && trim(implode('', $lines[0])) === '') {
array_shift($lines);
}
$header = [];
if (!empty($format['has_header_row']) && isset($lines[0])) {
$header = array_map('trim', $lines[0]);
$lines = array_slice($lines, 1);
}
$firstData = [];
foreach ($lines as $line) {
if (trim(implode('', $line)) !== '') {
$firstData = $line;
break;
}
}
return ['header' => $header, 'first_data' => $firstData];
} catch (\Throwable) {
return ['header' => [], 'first_data' => []];
}
}
/**
* Analysiert eine unbekannte CSV und versucht, die Headerzeile sowie passende
* Spaltenzuordnungen zu erkennen. Gibt null zurück, wenn keine Headerzeile gefunden.
*/
public function suggestFormat(string $filePath, string $ext): ?array
{
if (strtolower($ext) === 'xlsx') {
return null;
}
$patterns = [
'col_buchungstag' => ['buchungstag', 'buchungsdatum', 'datum'],
'col_wertstellung' => ['wertstellung'],
'col_vorgang' => ['vorgang', 'umsatztyp'],
'col_buchungstext' => ['buchungstext'],
'col_wert' => ['betrag', 'umsatz'],
'col_zahlungspflichtiger' => ['zahlungspflichtige', 'auftraggeber'],
'col_zahlungsempfaenger' => ['zahlungsempf', 'empfänger'],
'col_verwendungszweck' => ['verwendungszweck'],
];
foreach ([';', ',', "\t"] as $delimiter) {
try {
$lines = $this->readCsv($filePath, $delimiter, '"');
} catch (\Throwable) {
continue;
}
if (count($lines) < 2) {
continue;
}
for ($skipRows = 0; $skipRows <= 10; $skipRows++) {
$candidate = $lines[$skipRows] ?? null;
if ($candidate === null) {
break;
}
$cells = array_map('trim', $candidate);
$nonEmpty = array_filter($cells, fn($c) => $c !== '');
if (count($nonEmpty) < 3) {
continue;
}
// Zeile gilt als Header wenn Zellen Text enthalten (keine reinen Zahlen/Daten)
$textCells = array_filter($nonEmpty, function (string $c): bool {
if (is_numeric($c)) return false;
if (preg_match('/^\d{1,2}[.\-\/]\d{1,2}[.\-\/]\d{2,4}$/', $c)) return false;
return true;
});
if (count($textCells) < 2) {
continue;
}
// Mindestens ein Banking-Keyword muss vorkommen
$cellsLower = array_map('mb_strtolower', $cells);
$hasKeyword = false;
foreach ($patterns as $keywords) {
foreach ($keywords as $kw) {
foreach ($cellsLower as $cell) {
if (str_contains($cell, $kw)) {
$hasKeyword = true;
break 3;
}
}
}
}
if (!$hasKeyword) {
continue;
}
// Spalten-Mapping aufbauen
$mapping = [];
$used = [];
foreach ($patterns as $field => $keywords) {
foreach ($keywords as $kw) {
foreach ($cellsLower as $idx => $cell) {
if (!in_array($idx, $used, true) && str_contains($cell, $kw)) {
$mapping[$field] = $idx;
$used[] = $idx;
break 2;
}
}
}
}
// Erste Datenzeile nach dem Header ermitteln
$firstData = [];
for ($di = $skipRows + 1; $di < count($lines); $di++) {
$row = array_map('trim', $lines[$di]);
if (trim(implode('', $row)) !== '') {
$firstData = $row;
break;
}
}
return [
'skip_rows' => $skipRows,
'delimiter' => $delimiter,
'enclosure' => '"',
'has_header_row' => 1,
'header' => $cells,
'first_data' => $firstData,
'mapping' => $mapping,
];
}
}
return null;
}
/**
* Prüft, ob eine Datei mit dem angegebenen Format mindestens eine gültige Zeile liefert.
* Delegiert an parse(), damit Detection und Import immer identisch beurteilen.
*/
public function isFormatMatch(string $filePath, string $ext, array $format): bool
{
try {
return count($this->parse($filePath, $ext, $format)) > 0;
} catch (\Throwable) {
return false;
}
}
private function parseDate(string $value, string $format): ?string
{
$value = trim($value);

View file

@ -2,6 +2,7 @@
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
$months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
?>
@ -30,6 +31,39 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<?php endforeach; ?>
</select>
<?php
$selFmt = (int)($filter['format_id'] ?? 0);
$selFmtRow = null;
foreach ($formats as $f) { if ((int)$f['id'] === $selFmt) { $selFmtRow = $f; break; } }
?>
<div class="bank-fmt-filter" style="position:relative">
<input type="hidden" name="format_id" id="filter-format-id" value="<?= $selFmt ?: '' ?>">
<button type="button" id="filter-format-btn"
style="display:inline-flex;align-items:center;gap:.4rem;height:32px;padding:0 .6rem;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;cursor:pointer;white-space:nowrap;font-size:.875rem">
<?php if ($selFmtRow): ?>
<?php $fc = $selFmtRow['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($selFmtRow['kuerzel']) ?></span>
<?= $h($selFmtRow['name']) ?>
<?php else: ?>
<span style="color:var(--color-muted)">Alle Konten</span>
<?php endif; ?>
</button>
<div id="filter-format-drop"
style="display:none;position:absolute;top:100%;left:0;margin-top:2px;z-index:200;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius);box-shadow:0 4px 12px rgba(0,0,0,.08);min-width:180px;padding:.3rem 0">
<div class="fmt-opt" data-id="" style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem">
<span style="color:var(--color-muted)">Alle Konten</span>
</div>
<?php foreach ($formats as $f):
$fc = $f['farbe'] ?? '#6366f1'; ?>
<div class="fmt-opt" data-id="<?= (int)$f['id'] ?>"
style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem<?= $selFmt === (int)$f['id'] ? ';background:var(--color-bg)' : '' ?>">
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($f['kuerzel']) ?></span>
<?= $h($f['name']) ?>
</div>
<?php endforeach; ?>
</div>
</div>
<select name="typ" style="width:110px">
<option value="">Alle</option>
<option value="einnahme"<?= ($filter['typ'] ?? '') === 'einnahme' ? ' selected' : '' ?>>Einnahmen</option>
@ -45,6 +79,19 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option>
</select>
<select name="sort" style="width:140px">
<option value="wertstellung"<?= ($filter['sort'] ?? 'wertstellung') === 'wertstellung' ? ' selected' : '' ?>>Wertstellung</option>
<option value="buchungstag"<?= ($filter['sort'] ?? '') === 'buchungstag' ? ' selected' : '' ?>>Buchungstag</option>
<option value="betrag"<?= ($filter['sort'] ?? '') === 'betrag' ? ' selected' : '' ?>>Betrag</option>
<option value="buchungstext"<?= ($filter['sort'] ?? '') === 'buchungstext' ? ' selected' : '' ?>>Buchungstext</option>
<option value="status"<?= ($filter['sort'] ?? '') === 'status' ? ' selected' : '' ?>>Status</option>
</select>
<select name="dir" style="width:110px">
<option value="desc"<?= ($filter['dir'] ?? 'desc') === 'desc' ? ' selected' : '' ?>>↓ Absteigend</option>
<option value="asc"<?= ($filter['dir'] ?? 'desc') === 'asc' ? ' selected' : '' ?>>↑ Aufsteigend</option>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
<a href="/bank" class="btn btn-secondary btn-sm">Zurücksetzen</a>
</div>
@ -92,7 +139,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
<td>
<div><?= $h($row['buchungstext']) ?></div>
<div style="display:flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
<?php if (!empty($row['kuerzel'])): ?>
<?php $kf = $row['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($row['kuerzel']) ?></span>
<?php endif; ?>
<span><?= $h($row['buchungstext']) ?></span>
</div>
<?php if ($row['vorgang']): ?>
<div class="text-muted text-sm"><?= $h($row['vorgang']) ?></div>
<?php endif; ?>
@ -149,3 +202,33 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
</div>
<?php endif; ?>
</div>
<script>
(function () {
const btn = document.getElementById('filter-format-btn');
const drop = document.getElementById('filter-format-drop');
const input = document.getElementById('filter-format-id');
if (!btn || !drop) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
drop.style.display = drop.style.display === 'none' ? 'block' : 'none';
});
drop.addEventListener('click', (e) => {
const opt = e.target.closest('.fmt-opt');
if (!opt) return;
input.value = opt.dataset.id || '';
btn.closest('form').submit();
});
document.addEventListener('click', () => { drop.style.display = 'none'; });
drop.querySelectorAll('.fmt-opt').forEach(opt => {
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--color-bg)');
opt.addEventListener('mouseleave', () => {
opt.style.background = opt.dataset.id === input.value ? 'var(--color-bg)' : '';
});
});
})();
</script>

View file

@ -3,6 +3,7 @@ use Steuerkram\View;
use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
$wert = (float)$transaction['wert'];
$isPlus = $wert >= 0;
@ -34,7 +35,13 @@ $restFormatted = number_format($remaining, 2, ',', '.');
</div>
<div style="flex:1;min-width:200px">
<span class="text-muted text-sm">Buchungstext</span><br>
<span><?= $h($transaction['buchungstext']) ?></span>
<span style="display:inline-flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
<?php if (!empty($transaction['kuerzel'])): ?>
<?php $kf = $transaction['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($transaction['kuerzel']) ?></span>
<?php endif; ?>
<?= $h($transaction['buchungstext']) ?>
</span>
</div>
<div>
<span class="text-muted text-sm">Gesamt</span><br>

View file

@ -1,6 +1,6 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:700px">
<div class="card">
<div class="card-body">
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
@ -25,18 +25,83 @@
<div id="bank-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
</div>
<!-- Format-Auswahl -->
<div class="form-group">
<label for="format_id">Import-Format *</label>
<select id="format_id" name="format_id" required>
<!-- 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 class="form-hint">Format der hochgeladenen Datei</div>
</div>
<div style="display:flex;gap:.75rem;margin-top:1.5rem">
<!-- 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>
@ -50,24 +115,168 @@
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)">✓ ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>';
btn.disabled = false;
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) => {
@ -75,7 +284,6 @@
setActive(false);
const file = e.dataTransfer.files[0];
if (file) {
// DataTransfer-Datei in Input-Element spiegeln ist nicht möglich – Form-Submit via DataTransfer
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
@ -83,5 +291,21 @@
}
});
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>

View file

@ -3,6 +3,7 @@ use Steuerkram\View;
use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
$testMode = Setting::isTestMode();
$suppliers = $suppliers ?? [];
?>
@ -307,8 +308,14 @@ $suppliers = $suppliers ?? [];
<?php if ($lbt['vorgang']): ?>
<span class="text-sm text-muted"><?= $h($lbt['vorgang']) ?></span>
<?php endif; ?>
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
<?php if (!empty($lbt['kuerzel'])): ?>
<?php $kf = $lbt['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($lbt['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
</span>
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
<?php if (!empty($lbt['alloc_betrag']) && (float)$lbt['alloc_betrag'] !== abs($lbwert)): ?>
@ -337,8 +344,14 @@ $suppliers = $suppliers ?? [];
<span class="badge badge-blue" style="font-size:.8rem">Bankbuchung</span>
<span class="td-mono text-sm"><?= View::formatDate($bankTx['wertstellung']) ?></span>
<span class="text-sm"><?= $h($bankTx['vorgang']) ?></span>
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
<span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
<?php if (!empty($bankTx['kuerzel'])): ?>
<?php $kf = $bankTx['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($bankTx['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($bankTx['buchungstext']) ?>"><?= $h($bankTx['buchungstext']) ?></span>
</span>
<?php $bwert = (float)$bankTx['wert']; ?>
<strong style="color:<?= $bwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;white-space:nowrap">
<?= $bwert >= 0 ? '+' : '' ?><?= number_format($bwert, 2, ',', '.') ?> €

View file

@ -9,11 +9,21 @@ $fmt = fn($v) => View::formatMoney($v);
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
<div class="stat-meta">Betriebseinnahmen (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['umsatz_vormonat']) ?>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['umsatz_monat']) ?>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
<div class="stat-meta">Betriebsausgaben (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['ausgaben_vormonat']) ?>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['ausgaben_monat']) ?>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
@ -21,6 +31,11 @@ $fmt = fn($v) => View::formatMoney($v);
<?= $fmt($stats['gewinn']) ?>
</div>
<div class="stat-meta">EÜR-Ergebnis</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <span style="color:<?= $stats['gewinn_vormonat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_vormonat']) ?></span>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <span style="color:<?= $stats['gewinn_monat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_monat']) ?></span>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Offene Rechnungen</div>

View file

@ -93,6 +93,10 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg>
Kontenrahmen
</a>
<a href="/settings/bankimport" class="<?= navActive('/settings/bankimport', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
Bankimport
</a>
<?php endif; ?>
</nav>
</div>

View file

@ -0,0 +1,784 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
$dbLabels = [
'buchungstag' => 'Buchungstag',
'wertstellung' => 'Wertstellung',
'vorgang' => 'Vorgang / Umsatztyp',
'buchungstext' => 'Buchungstext',
'wert' => 'Betrag',
'zahlungspflichtiger' => 'Zahlungspflichtiger',
'zahlungsempfaenger' => 'Zahlungsempfänger',
'verwendungszweck' => 'Verwendungszweck',
];
$knownHeaders = [
'Comdirect' => [0=>'Buchungstag',1=>'Wertstellung',2=>'Vorgang',3=>'Buchungstext',4=>'Betrag'],
'DKB Bank' => [0=>'Buchungsdatum',1=>'Wertstellung',2=>'Status',3=>'Zahlungspflichtige*r',
4=>'Zahlungsempfänger*in',5=>'Verwendungszweck',6=>'Umsatztyp',7=>'IBAN',
8=>'Betrag (€)',9=>'Gläubiger-ID',10=>'Mandatsreferenz',11=>'Kundenreferenz'],
];
$dateFormatOptions = [
'd.m.Y' => '15.05.2026',
'd.m.y' => '15.05.26',
'Y-m-d' => '2026-05-15',
'd/m/Y' => '15/05/2026',
'm/d/Y' => '05/15/2026',
'Y.m.d' => '2026.05.15',
'd-m-Y' => '15-05-2026',
];
?>
<?php if (!empty($errors ?? [])): ?>
<div style="padding:.75rem 1rem;border-radius:var(--radius);background:#fee2e2;border:1px solid #fca5a5;margin-bottom:1rem;font-size:.875rem;color:#991b1b">
<?php foreach ($errors as $e): ?>
<div><?= $h($e) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($suggestion)): ?>
<?php
$sugHeader = $suggestion['header'] ?? [];
$sugMapping = $suggestion['mapping'] ?? [];
$allMappingFields = [
'buchungstag' => 'Buchungstag',
'wertstellung' => 'Wertstellung',
'vorgang' => 'Vorgang / Umsatztyp',
'wert' => 'Betrag',
'zahlungspflichtiger' => 'Zahlungspflichtiger',
'zahlungsempfaenger' => 'Zahlungsempfänger',
'verwendungszweck' => 'Verwendungszweck',
];
?>
<div class="card mb-2" id="suggestion-card" style="border:2px solid #6366f1">
<div class="card-header" style="background:#eef2ff;display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">Neues Format (automatischer Vorschlag)</div>
<span style="font-size:.75rem;font-weight:600;padding:.2rem .6rem;border-radius:9999px;background:#818cf8;color:#fff">Nicht gespeichert</span>
</div>
<div class="card-body">
<form method="post" action="/settings/bankimport/create">
<p style="font-size:.8rem;color:var(--color-muted);margin:0 0 1.25rem">
Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet.
Bitte prüfe die Einstellungen und klicke auf <strong>Speichern</strong>, um das neue Format anzulegen.
</p>
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Name</td>
<td><input type="text" name="name" value="Neues Format" required maxlength="100" style="width:220px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td><input type="text" name="kuerzel" value="NEU" required maxlength="10" style="width:80px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><input type="number" name="skip_rows" value="<?= (int)($suggestion['skip_rows'] ?? 0) ?>" min="0" max="99" style="width:70px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<select name="has_header_row" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<option value="1" <?= ($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Ja</option>
<option value="0" <?= !($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Nein</option>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><input type="text" name="delimiter" value="<?= $h($suggestion['delimiter'] ?? ';') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><input type="text" name="enclosure" value="<?= $h($suggestion['enclosure'] ?? '"') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<select name="date_format" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<?php foreach ($dateFormatOptions as $fmt => $example): ?>
<option value="<?= $h($fmt) ?>"><?= $h($fmt) ?> → <?= $h($example) ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><input type="text" name="decimal_separator" value="," maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><input type="text" name="thousands_separator" value="." maxlength="1" placeholder="(leer)" style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
</tbody>
</table>
<?php
$sugFirstData = $suggestion['first_data'] ?? [];
if (!empty($sugHeader) || !empty($sugFirstData)):
?>
<div class="bi-section-label">Erkannte Headerzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:.5rem">
<?php foreach ($sugHeader as $i => $col): ?>
<span title="Spalte <?= $i ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($col) ?></span><?php if ($i < count($sugHeader) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($sugFirstData)): ?>
<div style="font-size:.75rem;color:var(--color-muted);margin-bottom:.3rem">Erste Datenzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:1.5rem">
<?php foreach ($sugFirstData as $i => $val): ?>
<span title="Spalte <?= $i ?><?= isset($sugHeader[$i]) ? ': ' . htmlspecialchars($sugHeader[$i]) : '' ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($val) ?></span><?php if ($i < count($sugFirstData) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">Erkannte CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php foreach ($allMappingFields as $field => $label):
$colKey = 'col_' . $field;
$colIdx = $sugMapping[$colKey] ?? null;
$headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : '';
$isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true);
?>
<tr>
<td class="bi-label"><?= $h($label) ?></td>
<td>
<input type="number" name="col_<?= $h($field) ?>"
value="<?= $colIdx !== null ? $colIdx : '' ?>"
min="0" max="99"
<?= $isNullInt ? '' : 'required' ?>
style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($headerVal !== ''): ?>
<?= $h($headerVal) ?>
<?php if ($colIdx !== null): ?>
<span style="opacity:.5;font-size:.75rem"> (Sp.<?= $colIdx ?>)</span>
<?php endif; ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php if ($field === 'vorgang'): ?>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_debit" value=""
maxlength="500" placeholder="z.B. {Zahlungsempfänger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &gt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_credit" value=""
maxlength="500" placeholder="z.B. {Zahlungspflichtiger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates).
</td>
</tr>
</tbody>
</table>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" id="suggestion-dismiss" class="btn btn-secondary">Verwerfen</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<?php if (empty($formats)): ?>
<div class="card">
<div class="card-body" style="color:var(--color-muted)">Keine Importformate in der Datenbank vorhanden.</div>
</div>
<?php else: ?>
<p style="font-size:.875rem;color:var(--color-muted);margin:0 0 1rem">
Klick auf einen Wert zum Bearbeiten — Enter oder Klick außerhalb speichert, Escape bricht ab.
</p>
<?php foreach ($formats as $fmt): ?>
<?php
$fmtId = (int)$fmt['id'];
$hdrs = $knownHeaders[$fmt['name']] ?? [];
$mappingRowsBefore = [
['buchungstag', $fmt['col_buchungstag'], 'int'],
['wertstellung', $fmt['col_wertstellung'], 'int'],
['vorgang', $fmt['col_vorgang'], 'int'],
];
$mappingRowsAfter = [
['wert', $fmt['col_wert'], 'int'],
];
?>
<div class="card mb-2" id="format-<?= $fmtId ?>" data-format-id="<?= $fmtId ?>">
<div class="card-header" style="display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">
<span class="bi-cell" data-field="name" data-type="text" data-value="<?= $h($fmt['name']) ?>"><?= $h($fmt['name']) ?></span>
</div>
<button type="button" class="btn btn-danger btn-sm bi-delete-format" data-format-id="<?= $fmtId ?>" data-format-name="<?= $h($fmt['name']) ?>">Löschen</button>
</div>
<div class="card-body">
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td>
<?php $farbe = $fmt['farbe'] ?? '#6366f1'; ?>
<div style="display:inline-flex;align-items:center;gap:.5rem">
<span class="kuerzel-badge bi-kuerzel-preview"
style="background:<?= $h($farbe) ?>;color:<?= $kTextClr($farbe) ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<span class="bi-cell" data-field="kuerzel" data-type="text"
data-value="<?= $h($fmt['kuerzel'] ?? '') ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<input type="color" class="bi-color" data-field="farbe"
value="<?= $h($farbe) ?>"
title="Tag-Farbe anpassen"
style="width:28px;height:28px;padding:2px;border:1px solid var(--color-border);border-radius:4px;cursor:pointer;background:none;vertical-align:middle">
</div>
</td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><span class="bi-cell" data-field="skip_rows" data-type="number" data-min="0" data-max="99" data-value="<?= (int)$fmt['skip_rows'] ?>"><?= (int)$fmt['skip_rows'] ?></span></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<label class="bi-checkbox-label">
<input type="checkbox" class="bi-checkbox" data-field="has_header_row"
<?= $fmt['has_header_row'] ? 'checked' : '' ?>>
<span class="bi-checkbox-text"><?= $fmt['has_header_row'] ? 'Ja' : 'Nein' ?></span>
</label>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><span class="bi-cell" data-field="delimiter" data-type="char" data-value="<?= $h($fmt['delimiter']) ?>"><code><?= $h($fmt['delimiter']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><span class="bi-cell" data-field="enclosure" data-type="char" data-value="<?= $h($fmt['enclosure']) ?>"><code><?= $h($fmt['enclosure']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<span class="bi-cell" data-field="date_format" data-type="dateformat" data-value="<?= $h($fmt['date_format']) ?>">
<code><?= $h($fmt['date_format']) ?></code>
<span style="color:var(--color-muted);margin-left:.4rem">→ <?= $h($dateFormatOptions[$fmt['date_format']] ?? '?') ?></span>
</span>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><span class="bi-cell" data-field="decimal_separator" data-type="char" data-value="<?= $h($fmt['decimal_separator']) ?>"><code><?= $h($fmt['decimal_separator']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><span class="bi-cell" data-field="thousands_separator" data-type="char0" data-value="<?= $h($fmt['thousands_separator']) ?>">
<?php if ($fmt['thousands_separator'] !== ''): ?>
<code><?= $h($fmt['thousands_separator']) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">keines</span>
<?php endif; ?>
</span></td>
</tr>
</tbody>
</table>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php
$renderMappingRow = function (string $field, mixed $colIdx, string $colType) use ($h, $hdrs, $dbLabels): void {
$colVal = $colIdx !== null ? (int)$colIdx : null;
?>
<tr>
<td class="bi-label"><?= $h($dbLabels[$field] ?? $field) ?></td>
<td>
<span class="bi-cell"
data-field="col_<?= $h($field) ?>"
data-type="<?= $colType === 'nullint' ? 'nullint' : 'number' ?>"
data-min="0" data-max="99"
data-value="<?= $colVal !== null ? $colVal : '' ?>">
<?php if ($colVal !== null): ?>
<code><?= $colVal ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">–</span>
<?php endif; ?>
</span>
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($colVal !== null && isset($hdrs[$colVal])): ?>
<?= $h($hdrs[$colVal]) ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php }; ?>
<?php foreach ($mappingRowsBefore as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<?php
// Buchungstext-Template-Zeilen (immer)
foreach ([
['buchungstext_template_debit', 'Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small>', $fmt['buchungstext_template_debit'] ?? ''],
['buchungstext_template_credit', 'Buchungstext <small style="font-weight:400">(Betrag ≥ 0)</small>', $fmt['buchungstext_template_credit'] ?? ''],
] as [$tplField, $tplLabel, $tplValue]):
?>
<tr>
<td class="bi-label"><?= $tplLabel ?></td>
<td colspan="2" style="padding-right:0">
<span class="bi-cell bi-cell--wide" data-field="<?= $h($tplField) ?>" data-type="template"
data-value="<?= $h($tplValue) ?>">
<?php if ($tplValue !== ''): ?>
<code style="word-break:break-all"><?= $h($tplValue) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>
<?php endif; ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($mappingRowsAfter as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<style>
.bi-section-label {
font-size: .8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-muted);
margin-bottom: .4rem;
}
.bi-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.bi-table th,
.bi-table td {
padding: .35rem .6rem .35rem 0;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.bi-table thead th { color: var(--color-muted); font-weight: 500; }
.bi-table tbody tr:last-child td { border-bottom: none; }
.bi-label { color: var(--color-text); width: 38%; }
.bi-cell {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .15rem .3rem;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid transparent;
transition: border-color .15s, background .15s;
min-height: 1.6rem;
min-width: 2rem;
}
.bi-cell:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.bi-cell::after {
content: '✎';
font-size: .7rem;
color: var(--color-muted);
opacity: 0;
transition: opacity .15s;
flex-shrink: 0;
}
.bi-cell:hover::after { opacity: 1; }
.bi-cell input,
.bi-cell select {
font-size: inherit;
font-family: inherit;
padding: 2px 5px;
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: #fff;
outline: none;
}
.bi-cell input[type="number"] { width: 70px; }
.bi-cell input[type="text"] { width: 60px; }
.bi-cell select { width: auto; min-width: 180px; }
.bi-cell--saving { opacity: .5; pointer-events: none; }
.bi-cell--error { border-color: var(--color-danger) !important; background: #fee2e2 !important; }
.bi-cell--wide { display: flex; width: 100%; box-sizing: border-box; }
.bi-checkbox-label {
display: inline-flex;
align-items: center;
gap: .4rem;
cursor: pointer;
padding: .15rem .3rem;
border-radius: var(--radius);
border: 1px solid transparent;
transition: border-color .15s, background .15s;
user-select: none;
}
.bi-checkbox-label:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.bi-checkbox { cursor: pointer; }
.bi-error-msg {
font-size: .8rem;
color: var(--color-danger);
margin-top: .2rem;
display: block;
}
</style>
<script>
(function () {
const DATE_FORMAT_OPTIONS = <?= json_encode($dateFormatOptions) ?>;
function badgeTextColor(hex) {
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 (0.299*r + 0.587*g + 0.114*b) > 128 ? '#1e293b' : '#ffffff';
}
function updatePreview(colorInput, hex) {
const preview = colorInput.closest('div')?.querySelector('.bi-kuerzel-preview');
if (!preview) return;
preview.style.background = hex;
preview.style.color = badgeTextColor(hex);
}
// ── Hilfsfunktionen ──────────────────────────────────────────────
function getFormatId(cell) {
return cell.closest('[data-format-id]')?.dataset.formatId;
}
function showError(cell, msg) {
cell.classList.add('bi-cell--error');
let err = cell.parentElement.querySelector('.bi-error-msg');
if (!err) { err = document.createElement('span'); err.className = 'bi-error-msg'; cell.after(err); }
err.textContent = msg;
setTimeout(() => { cell.classList.remove('bi-cell--error'); err.remove(); }, 3000);
}
async function saveField(formatId, field, value) {
const body = new URLSearchParams({ field, value: value ?? '' });
const res = await fetch(`/settings/bankimport/${formatId}/field`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return res.json();
}
function displayForCell(type, value, field) {
if (type === 'number' || type === 'nullint') {
if (value === '' || value === null || value === undefined) {
return '<span style="color:var(--color-muted);font-style:italic">–</span>';
}
return `<code>${value}</code>`;
}
if (type === 'char' || type === 'char0') {
if (value === '') return '<span style="color:var(--color-muted);font-style:italic">keines</span>';
return `<code>${value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</code>`;
}
if (type === 'dateformat') {
const example = DATE_FORMAT_OPTIONS[value] || '?';
return `<code>${value}</code><span style="color:var(--color-muted);margin-left:.4rem">→ ${example}</span>`;
}
if (type === 'template') {
if (!value) return '<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>';
const esc = value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<code style="word-break:break-all">${esc}</code>`;
}
if (type === 'text') {
return value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
return value;
}
// ── Klick-Handler für .bi-cell ───────────────────────────────────
document.addEventListener('click', (e) => {
const cell = e.target.closest('.bi-cell');
if (!cell || cell.querySelector('input,select')) return;
const field = cell.dataset.field;
const type = cell.dataset.type;
const rawValue = cell.dataset.value ?? '';
const formatId = getFormatId(cell);
if (!formatId) return;
const originalHTML = cell.innerHTML;
cell.innerHTML = '';
cell.classList.remove('bi-cell--error');
let el;
if (type === 'number' || type === 'nullint') {
el = document.createElement('input');
el.type = 'number';
el.min = cell.dataset.min ?? '0';
el.max = cell.dataset.max ?? '99';
el.value = rawValue;
} else if (type === 'char') {
el = document.createElement('input');
el.type = 'text';
el.maxLength = 1;
el.value = rawValue;
} else if (type === 'char0') {
el = document.createElement('input');
el.type = 'text';
el.maxLength = 1;
el.value = rawValue;
el.placeholder = '(leer)';
el.style.width = '70px';
} else if (type === 'dateformat') {
el = document.createElement('select');
for (const [fmt, example] of Object.entries(DATE_FORMAT_OPTIONS)) {
const opt = document.createElement('option');
opt.value = fmt;
opt.textContent = `${fmt} → ${example}`;
if (fmt === rawValue) opt.selected = true;
el.appendChild(opt);
}
} else if (type === 'template') {
el = document.createElement('input');
el.type = 'text';
el.value = rawValue;
el.style.width = '100%';
el.style.boxSizing = 'border-box';
} else {
el = document.createElement('input');
el.type = 'text';
el.value = rawValue;
el.style.width = '180px';
}
cell.appendChild(el);
el.focus();
if (el.tagName === 'INPUT' && el.type === 'text') el.select();
let done = false;
async function commit() {
if (done) return;
done = true;
const newVal = el.tagName === 'SELECT' ? el.value : el.value;
cell.classList.add('bi-cell--saving');
cell.innerHTML = '';
let json;
try {
json = await saveField(formatId, field, newVal);
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
cell.classList.remove('bi-cell--saving');
if (json.error) {
cell.innerHTML = originalHTML;
showError(cell, json.error);
return;
}
cell.dataset.value = newVal;
cell.innerHTML = displayForCell(type, newVal, field);
// Formatname im Card-Header synchronisieren
if (field === 'name') {
const title = cell.closest('.card')?.querySelector('.card-title');
if (title) title.dataset.value = newVal;
}
// Kürzel-Vorschau-Badge aktualisieren
if (field === 'kuerzel') {
const preview = cell.closest('div')?.querySelector('.bi-kuerzel-preview');
if (preview) preview.textContent = newVal;
}
}
function cancel() {
if (done) return;
done = true;
cell.innerHTML = originalHTML;
}
if (el.tagName === 'SELECT') {
el.addEventListener('change', commit);
el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); cancel(); } });
el.addEventListener('blur', () => { if (!done) commit(); });
} else {
el.addEventListener('blur', commit);
el.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); el.blur(); }
if (ev.key === 'Escape') { ev.preventDefault(); cancel(); }
});
}
});
// ── Colorpicker für farbe ────────────────────────────────────────
document.addEventListener('input', (e) => {
const ci = e.target.closest('.bi-color');
if (ci) updatePreview(ci, ci.value);
});
document.addEventListener('change', async (e) => {
const ci = e.target.closest('.bi-color');
if (!ci) return;
const formatId = getFormatId(ci);
const field = ci.dataset.field;
const value = ci.value;
ci.disabled = true;
let json;
try { json = await saveField(formatId, field, value); }
catch (_) { json = { error: 'Verbindungsfehler.' }; }
ci.disabled = false;
if (json.error) {
showError(ci, json.error);
} else {
updatePreview(ci, value);
}
});
// ── Format löschen ──────────────────────────────────────────────
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.bi-delete-format');
if (!btn) return;
const formatId = btn.dataset.formatId;
const formatName = btn.dataset.formatName;
if (!confirm(`Format „${formatName}" wirklich löschen?`)) return;
btn.disabled = true;
let json;
try {
const res = await fetch(`/settings/bankimport/${formatId}/delete`, { method: 'POST' });
json = await res.json();
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
if (json.error) {
alert(json.error);
btn.disabled = false;
return;
}
btn.closest('.card').remove();
});
// ── Vorschlags-Card verwerfen ────────────────────────────────────
const dismissBtn = document.getElementById('suggestion-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', async () => {
dismissBtn.disabled = true;
try {
await fetch('/settings/bankimport/dismiss-suggestion', { method: 'POST' });
} catch (_) {}
const card = document.getElementById('suggestion-card');
if (card) card.remove();
});
}
// ── Checkboxen für has_header_row ────────────────────────────────
document.addEventListener('change', async (e) => {
const cb = e.target.closest('.bi-checkbox');
if (!cb) return;
const label = cb.closest('.bi-checkbox-label');
const textSpan = label?.querySelector('.bi-checkbox-text');
const field = cb.dataset.field;
const formatId = getFormatId(cb);
if (!formatId) return;
const newVal = cb.checked ? '1' : '0';
cb.disabled = true;
let json;
try {
json = await saveField(formatId, field, newVal);
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
cb.disabled = false;
if (json.error) {
cb.checked = !cb.checked; // Revert
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
alert(json.error);
return;
}
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
});
})();
</script>

View file

@ -1 +1 @@
1-260516
1-260517

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* Test 34: BankImportParser – DKB-Format mit Template-basiertem Buchungstext.
*/
class BankImportDkbTest extends TestCase
{
protected string $testName = 'Test 34: BankImportParser – DKB-Format (Template)';
private function makeTempCsv(string $content): string
{
$path = sys_get_temp_dir() . '/test34_' . uniqid() . '.csv';
file_put_contents($path, $content);
return $path;
}
private function dkbFormat(): array
{
return [
'skip_rows' => 4,
'has_header_row' => 1,
'delimiter' => ';',
'enclosure' => '"',
'col_buchungstag' => 0,
'col_wertstellung' => 1,
'col_vorgang' => 6,
'col_buchungstext' => 5,
'col_wert' => 8,
'buchungstext_template_debit' => 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}',
'buchungstext_template_credit' => 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}',
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
];
}
private function dkbHeader(): string
{
return '"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";'
. '"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";'
. '"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"';
}
public function run(): void
{
$parser = new \Steuerkram\Services\BankImportParser();
$fmt = $this->dkbFormat();
// ── Negativer Betrag: Zahlungsempfänger*in im Buchungstext ───────
$csv = "Skip1\nSkip2\nSkip3\nSkip4\n"
. $this->dkbHeader() . "\n"
. '"15.05.2026";"15.05.2026";"Gebucht";"Max Muster";"Amazon GmbH";"Abo Monatsbeitrag";"Lastschrift";"DE123";"- 9,99";"GL01";"MR01";"KR01"' . "\n";
$tmp = $this->makeTempCsv($csv);
$rows = $parser->parse($tmp, 'csv', $fmt);
unlink($tmp);
$this->assertEquals(1, count($rows), 'DKB negativer Betrag: 1 Buchung geparst');
$this->assertEquals('2026-05-15', $rows[0]['buchungstag'], 'Buchungstag korrekt');
$this->assertEquals('2026-05-15', $rows[0]['wertstellung'], 'Wertstellung korrekt');
$this->assertEquals('Lastschrift', $rows[0]['vorgang'], 'Umsatztyp als vorgang');
$this->assertEquals(-9.99, $rows[0]['wert'], 'Betrag -9,99 korrekt');
$this->assertEquals(
'Auftraggeber: Amazon GmbH Buchungstext: Abo Monatsbeitrag',
$rows[0]['buchungstext'],
'Negativer Betrag: Template debit mit Zahlungsempfänger*in'
);
// ── Positiver Betrag: Zahlungspflichtige*r im Buchungstext ───────
$csv2 = "Skip1\nSkip2\nSkip3\nSkip4\n"
. $this->dkbHeader() . "\n"
. '"14.05.2026";"14.05.2026";"Gebucht";"Kunde AG";"Eigenkonto";"Rechnung 2026-042";"Gutschrift";"DE456";"1.500,00";"";"";"KR02"' . "\n";
$tmp2 = $this->makeTempCsv($csv2);
$rows2 = $parser->parse($tmp2, 'csv', $fmt);
unlink($tmp2);
$this->assertEquals(1, count($rows2), 'DKB positiver Betrag: 1 Buchung geparst');
$this->assertEquals(1500.00, $rows2[0]['wert'], 'Betrag 1.500,00 korrekt');
$this->assertEquals(
'Auftraggeber: Kunde AG Buchungstext: Rechnung 2026-042',
$rows2[0]['buchungstext'],
'Positiver Betrag: Template credit mit Zahlungspflichtige*r'
);
// ── Betrag 0: credit-Template (>= 0) ────────────────────────────
$csv3 = "Skip1\nSkip2\nSkip3\nSkip4\n"
. $this->dkbHeader() . "\n"
. '"13.05.2026";"13.05.2026";"Gebucht";"Nullbetrag GmbH";"Gegenkonto";"Storno";"Umbuchung";"DE789";"0,00";"";"";"KR03"' . "\n";
$tmp3 = $this->makeTempCsv($csv3);
$rows3 = $parser->parse($tmp3, 'csv', $fmt);
unlink($tmp3);
$this->assertEquals(1, count($rows3), 'DKB Betrag 0: 1 Buchung geparst');
$this->assertEquals(
'Auftraggeber: Nullbetrag GmbH Buchungstext: Storno',
$rows3[0]['buchungstext'],
'Betrag 0: credit-Template genutzt'
);
// ── Unbekannte Placeholder werden als leer aufgelöst ─────────────
$fmtUnknown = $fmt;
$fmtUnknown['buchungstext_template_debit'] = 'Test: {NichtVorhanden} Ende';
$csv5 = "Skip1\nSkip2\nSkip3\nSkip4\n"
. $this->dkbHeader() . "\n"
. '"12.05.2026";"12.05.2026";"Gebucht";"A";"B";"C";"Lastschrift";"DE";"- 1,00";"";"";"KR05"' . "\n";
$tmp5 = $this->makeTempCsv($csv5);
$rows5 = $parser->parse($tmp5, 'csv', $fmtUnknown);
unlink($tmp5);
$this->assertEquals('Test: Ende', $rows5[0]['buchungstext'],
'Unbekannter Placeholder wird als leerer String aufgelöst');
// ── Format ohne Template: col_buchungstext wird direkt genutzt ───
$fmtStd = [
'skip_rows' => 3,
'has_header_row' => 1,
'delimiter' => ';',
'enclosure' => '"',
'col_buchungstag' => 0,
'col_wertstellung' => 1,
'col_vorgang' => 2,
'col_buchungstext' => 3,
'col_wert' => 4,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
];
$csv4 = "Z1\nZ2\nZ3\n"
. "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n"
. "15.05.2026;15.05.2026;Lastschrift;Normaler Text;\"-50,00\"\n";
$tmp4 = $this->makeTempCsv($csv4);
$rows4 = $parser->parse($tmp4, 'csv', $fmtStd);
unlink($tmp4);
$this->assertEquals('Normaler Text', $rows4[0]['buchungstext'],
'Format ohne Template: col_buchungstext direkt genutzt');
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\BankImportParser;
/**
* Test 35: BankImportParser::suggestFormat() – automatische Format-Erkennung.
*/
class BankImportSuggestTest extends TestCase
{
protected string $testName = 'Test 35: BankImportParser – automatische Format-Erkennung';
private function makeTempCsv(string $content): string
{
$path = sys_get_temp_dir() . '/test35_' . uniqid() . '.csv';
file_put_contents($path, $content);
return $path;
}
public function run(): void
{
$parser = new BankImportParser();
// CSV mit 2 Metazeilen vor dem Header (DKB-ähnlich)
$csv = implode("\n", [
'"Kontonummer:";"DE12 3456 7890 1234 5678 90";',
'"Von:";"01.01.2026";"Bis:";"31.01.2026";',
'"Kontostand vom 31.01.2026:";"1.234,56 EUR";',
'"";',
'"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)"',
'"15.01.2026";"15.01.2026";"Gebucht";"Max Mustermann";"REWE";"Lebensmittel";"Lastschrift";"DE11111";"−45,30"',
]);
$file = $this->makeTempCsv($csv);
$result = $parser->suggestFormat($file, 'csv');
$this->assert($result !== null, 'suggestFormat() gibt ein Ergebnis zurück');
$this->assertEquals(4, $result['skip_rows'], 'skip_rows korrekt erkannt (4)');
$this->assertEquals(';', $result['delimiter'], 'Delimiter korrekt erkannt (;)');
$this->assertEquals(1, $result['has_header_row'], 'has_header_row gesetzt');
$this->assertNotEmpty($result['header'], 'header-Array nicht leer');
$this->assertNotEmpty($result['mapping'], 'mapping-Array nicht leer');
$this->assert(isset($result['mapping']['col_buchungstag']), 'col_buchungstag gemappt');
$this->assert(isset($result['mapping']['col_wertstellung']), 'col_wertstellung gemappt');
$this->assert(isset($result['mapping']['col_wert']), 'col_wert gemappt (Betrag)');
unlink($file);
// CSV ohne Banking-Keywords → kein Vorschlag
$csvOhneKeywords = implode("\n", [
'Foo;Bar;Baz',
'1;2;3',
]);
$file2 = $this->makeTempCsv($csvOhneKeywords);
$result2 = $parser->suggestFormat($file2, 'csv');
$this->assert($result2 === null, 'suggestFormat() gibt null zurück wenn kein Banking-Header erkannt');
unlink($file2);
// Comdirect-ähnliche CSV (kein skip_rows)
$csvComdirect = implode("\n", [
'"Buchungstag";"Wertstellung";"Vorgang";"Buchungstext";"Betrag (€)"',
'"10.01.2026";"10.01.2026";"Lastschrift";"Amazon";"−19,99"',
]);
$file3 = $this->makeTempCsv($csvComdirect);
$result3 = $parser->suggestFormat($file3, 'csv');
$this->assert($result3 !== null, 'suggestFormat() erkennt Comdirect-ähnliche CSV');
$this->assertEquals(0, $result3['skip_rows'], 'skip_rows=0 bei direktem Header');
unlink($file3);
}
}