Feature: Bank buchen – Import, Liste, Detail, Beleg-Verknüpfung

This commit is contained in:
fds2610-bot 2026-05-14 19:56:26 +02:00
parent b28ac191fa
commit 02c642e53e
11 changed files with 1008 additions and 0 deletions

View file

@ -2,6 +2,18 @@
---
## 2026-05-14 – Feature: Bank buchen
- Navigation: neuer Eintrag „Bank buchen" unter Belege & Buchungen
- Migration 049: Tabellen `bank_import_formats`, `bank_import_batches`, `bank_transactions`; Comdirect-Format als erster Eintrag
- `BankImportParser`: Parst CSV/TXT (quoted, Semikolon-getrennt) und XLSX (via ZipArchive/SimpleXML, kein Composer-Paket)
- Liste `/bank`: Suche + Filter (Jahr, Monat, Einnahme/Ausgabe, Status); Standard-Filter: offen; Einnahmen grün, Ausgaben rot
- Upload `/bank/upload`: Drag-&-Drop-Zone für CSV/TXT/XLSX + Format-Auswahl
- Detail `/bank/{id}`: Bank-Zeile oben; Beleg-Upload; Liste offener Belege zum Verknüpfen
- Status-Aktionen: „Ignorieren" / „Wieder öffnen"; Verknüpfen eines Belegs setzt Status auf „gebucht"
---
## 2026-05-14 – Feature: UStVA Kz 83 = ust81 + ust89 − Kz66 − Kz61
- `UstvaCalculator`: Kz 83 wird nun als `ust81 + ust89 − Kz66 − Kz61` berechnet (nicht mehr kontobasiert via ustva_kz='83')

View file

@ -0,0 +1,56 @@
-- Bank-Import-Formate
CREATE TABLE IF NOT EXISTS bank_import_formats (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
skip_rows INT NOT NULL DEFAULT 3,
has_header_row TINYINT(1) NOT NULL DEFAULT 1,
delimiter CHAR(1) NOT NULL DEFAULT ';',
enclosure CHAR(1) NOT NULL DEFAULT '"',
col_buchungstag INT NOT NULL DEFAULT 0,
col_wertstellung INT NOT NULL DEFAULT 1,
col_vorgang INT NOT NULL DEFAULT 2,
col_buchungstext INT NOT NULL DEFAULT 3,
col_wert INT NOT NULL DEFAULT 4,
date_format VARCHAR(20) NOT NULL DEFAULT 'd.m.Y',
decimal_separator CHAR(1) NOT NULL DEFAULT ',',
thousands_separator CHAR(1) NOT NULL DEFAULT '.',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Import-Batches (Datei-Uploads)
CREATE TABLE IF NOT EXISTS bank_import_batches (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
format_id INT UNSIGNED NOT NULL,
filename VARCHAR(255) NOT NULL,
row_count INT NOT NULL DEFAULT 0,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (format_id) REFERENCES bank_import_formats(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Importierte Bankbuchungen
CREATE TABLE IF NOT EXISTS bank_transactions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
batch_id INT UNSIGNED NOT NULL,
buchungstag DATE NOT NULL,
wertstellung DATE NOT NULL,
vorgang VARCHAR(255) NOT NULL DEFAULT '',
buchungstext TEXT NOT NULL,
wert DECIMAL(12,2) NOT NULL,
status ENUM('offen','gebucht','ignoriert') NOT NULL DEFAULT 'offen',
beleg_id INT UNSIGNED NULL DEFAULT NULL,
raw_line TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (batch_id) REFERENCES bank_import_batches(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Comdirect-Format: 3 Zeilen überspringen, dann Headerzeile, dann Daten
-- Semikolon-getrennt, Anführungszeichen-umschlossen, deutsches Datumsformat
INSERT INTO bank_import_formats
(name, skip_rows, has_header_row, delimiter, enclosure,
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
date_format, decimal_separator, thousands_separator)
VALUES
('Comdirect', 3, 1, ';', '"',
0, 1, 2, 3, 4,
'd.m.Y', ',', '.')
ON DUPLICATE KEY UPDATE name = name;

View file

@ -26,6 +26,7 @@ use Steuerkram\Controllers\SettingsController;
use Steuerkram\Controllers\LoginController;
use Steuerkram\Controllers\AssetController;
use Steuerkram\Controllers\UserAdminController;
use Steuerkram\Controllers\BankController;
Config::load();
@ -63,6 +64,15 @@ $router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImag
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));
// ── Bank buchen ──────────────────────────────────────────────
$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->get('/bank/{id}', fn($p) => (new BankController())->show($p));
$router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($p));
$router->post('/bank/{id}/link', fn($p) => (new BankController())->linkBeleg($p));
$router->post('/bank/{id}/beleg', fn($p) => (new BankController())->uploadBeleg($p));
// ── Buchungen ────────────────────────────────────────────────
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
$router->get('/journal/export', fn($p) => (new BookingController())->export($p));

View file

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Models\Beleg;
use Steuerkram\Services\BankImportParser;
use Steuerkram\View;
class BankController
{
private const ALLOWED_EXT = ['csv', 'txt', 'xlsx'];
public function index(array $params): void
{
$filter = $_GET;
if (!isset($filter['status'])) {
$filter['status'] = 'offen';
}
$page = max(1, (int)($_GET['page'] ?? 1));
$result = BankTransaction::all($filter, $page);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bank buchen',
'content' => $this->renderView('bank/index', [
'rows' => $result['rows'],
'total' => $result['total'],
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
]),
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
]);
}
public function uploadForm(array $params): void
{
$formats = BankImportFormat::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bank-Buchungen importieren',
'content' => $this->renderView('bank/upload', [
'formats' => $formats,
'errors' => [],
]),
]);
}
public function upload(array $params): void
{
$formatId = (int)($_POST['format_id'] ?? 0);
$format = BankImportFormat::find($formatId);
if (!$format) {
View::redirect('/bank/upload', 'Unbekanntes Import-Format.', 'error');
}
$file = $_FILES['bank_file'] ?? null;
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
View::redirect('/bank/upload', 'Datei-Upload fehlgeschlagen.', 'error');
}
$origName = $file['name'] ?? 'upload';
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, self::ALLOWED_EXT, true)) {
View::redirect('/bank/upload', 'Nicht erlaubtes Dateiformat. Erlaubt: CSV, TXT, XLSX.', 'error');
}
try {
$parser = new BankImportParser();
$rows = $parser->parse($file['tmp_name'], $ext, $format);
} catch (\RuntimeException $e) {
View::redirect('/bank/upload', 'Fehler beim Parsen: ' . $e->getMessage(), 'error');
}
if (empty($rows)) {
View::redirect('/bank/upload', 'Keine Buchungszeilen in der Datei gefunden.', 'error');
}
$batchId = BankTransaction::createBatch($formatId, $origName, count($rows));
foreach ($rows as $row) {
BankTransaction::insertRow($batchId, $row);
}
View::redirect('/bank', count($rows) . ' Buchungen importiert.');
}
public function show(array $params): void
{
$id = (int)($params['id'] ?? 0);
$transaction = BankTransaction::find($id);
if (!$transaction) {
View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
}
// Offene Belege (noch nicht gebucht)
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bankbuchung zuordnen',
'content' => $this->renderView('bank/show', [
'transaction' => $transaction,
'offeneBelege' => $offeneBelege['rows'],
]),
]);
}
public function updateStatus(array $params): void
{
$id = (int)($params['id'] ?? 0);
$status = $_POST['status'] ?? '';
if (!in_array($status, ['offen', 'gebucht', 'ignoriert'], true)) {
View::redirect('/bank', 'Ungültiger Status.', 'error');
}
BankTransaction::updateStatus($id, $status);
View::redirect('/bank', 'Status aktualisiert.');
}
public function linkBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0);
if (!$belegId) {
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
}
BankTransaction::linkBeleg($id, $belegId);
View::redirect('/bank', 'Beleg verknüpft und Buchung als gebucht markiert.');
}
public function uploadBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
$transaction = BankTransaction::find($id);
if (!$transaction) {
View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
}
// Beleg über BelegController-Logik hochladen und verknüpfen
$file = $_FILES['beleg_file'] ?? null;
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
View::redirect("/bank/$id", 'Datei-Upload fehlgeschlagen.', 'error');
}
$allowedMime = [
'application/pdf' => 'pdf',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/tiff' => 'tif',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
$mime = mime_content_type($file['tmp_name']) ?: '';
if (!isset($allowedMime[$mime])) {
View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error');
}
$ext = $allowedMime[$mime];
$hash = hash_file('sha256', $file['tmp_name']);
$filename = $hash . '.' . $ext;
$dir = BASE_DIR . '/storage/belege/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$dest = $dir . $filename;
if (!file_exists($dest)) {
move_uploaded_file($file['tmp_name'], $dest);
}
$belegId = Beleg::create([
'beleg_nr' => Beleg::generateBelegNr(),
'filename' => $file['name'],
'storage_path' => 'storage/belege/' . $filename,
'mime_type' => $mime,
'file_hash' => $hash,
'beleg_datum' => $transaction['wertstellung'],
'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung',
]);
BankTransaction::linkBeleg($id, $belegId);
View::redirect('/bank', 'Beleg hochgeladen und Buchung als gebucht markiert.');
}
private function renderView(string $name, array $data = []): string
{
$viewFile = dirname(__DIR__) . '/Views/' . $name . '.php';
extract($data, EXTR_SKIP);
ob_start();
include $viewFile;
return ob_get_clean();
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class BankImportFormat
{
public static function all(): array
{
return Database::fetchAll('SELECT * FROM bank_import_formats ORDER BY name');
}
public static function find(int $id): ?array
{
$row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]);
return $row ?: null;
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class BankTransaction
{
public static function all(array $filter = [], int $page = 1, int $perPage = 50): array
{
$where = ['1=1'];
$params = [];
$status = $filter['status'] ?? 'offen';
if ($status !== '') {
$where[] = 'bt.status = ?';
$params[] = $status;
}
if (!empty($filter['year'])) {
$where[] = 'YEAR(bt.wertstellung) = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['month'])) {
$where[] = 'MONTH(bt.wertstellung) = ?';
$params[] = (int)$filter['month'];
}
if (!empty($filter['typ'])) {
if ($filter['typ'] === 'einnahme') {
$where[] = 'bt.wert > 0';
} elseif ($filter['typ'] === 'ausgabe') {
$where[] = 'bt.wert < 0';
}
}
if (!empty($filter['search'])) {
$where[] = '(bt.buchungstext LIKE ? OR bt.vorgang LIKE ?)';
$s = '%' . $filter['search'] . '%';
$params[] = $s;
$params[] = $s;
}
$whereStr = implode(' AND ', $where);
$offset = ($page - 1) * $perPage;
$total = (int)(Database::fetchOne(
"SELECT COUNT(*) AS n FROM bank_transactions bt WHERE $whereStr",
$params
)['n'] ?? 0);
$rows = Database::fetchAll(
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name
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
WHERE $whereStr
ORDER BY bt.wertstellung DESC, bt.id DESC
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
return [
'rows' => $rows,
'total' => $total,
'pages' => (int)ceil($total / $perPage),
];
}
public static function find(int $id): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name
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
WHERE bt.id = ?',
[$id]
);
return $row ?: null;
}
public static function updateStatus(int $id, string $status): void
{
Database::execute(
"UPDATE bank_transactions SET status = ? WHERE id = ?",
[$status, $id]
);
}
public static function linkBeleg(int $id, int $belegId): void
{
Database::execute(
"UPDATE bank_transactions SET beleg_id = ?, status = 'gebucht' WHERE id = ?",
[$belegId, $id]
);
}
public static function createBatch(int $formatId, string $filename, int $rowCount): int
{
Database::execute(
'INSERT INTO bank_import_batches (format_id, filename, row_count) VALUES (?, ?, ?)',
[$formatId, $filename, $rowCount]
);
return (int)Database::lastInsertId();
}
public static function insertRow(int $batchId, array $data): void
{
Database::execute(
'INSERT INTO bank_transactions
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line)
VALUES (?, ?, ?, ?, ?, ?, ?)',
[
$batchId,
$data['buchungstag'],
$data['wertstellung'],
$data['vorgang'],
$data['buchungstext'],
$data['wert'],
$data['raw_line'] ?? null,
]
);
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
class BankImportParser
{
/**
* Parst eine hochgeladene Bank-Datei (CSV/TXT/XLSX) anhand des Formats.
* Gibt ein Array von Zeilen zurück, jede Zeile als assoc array.
*
* @throws \RuntimeException bei Parsefehlern
*/
public function parse(string $filePath, string $extension, array $format): array
{
$ext = strtolower($extension);
if ($ext === 'xlsx') {
$lines = $this->readXlsx($filePath);
} else {
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
}
// Übersprungene Zeilen und optionale Headerzeile entfernen
$skipRows = (int)$format['skip_rows'];
$lines = array_slice($lines, $skipRows);
if (!empty($format['has_header_row'])) {
$lines = array_slice($lines, 1);
}
$colBuchungstag = (int)$format['col_buchungstag'];
$colWertstellung = (int)$format['col_wertstellung'];
$colVorgang = (int)$format['col_vorgang'];
$colBuchungstext = (int)$format['col_buchungstext'];
$colWert = (int)$format['col_wert'];
$result = [];
foreach ($lines as $i => $cells) {
// Leerzeilen überspringen
$joined = implode('', $cells);
if (trim($joined) === '') {
continue;
}
$buchungstag = $this->parseDate($cells[$colBuchungstag] ?? '', $format['date_format']);
$wertstellung = $this->parseDate($cells[$colWertstellung] ?? '', $format['date_format']);
$wert = $this->parseDecimal(
$cells[$colWert] ?? '0',
$format['decimal_separator'],
$format['thousands_separator']
);
if ($buchungstag === null || $wertstellung === null) {
continue;
}
$result[] = [
'buchungstag' => $buchungstag,
'wertstellung' => $wertstellung,
'vorgang' => trim($cells[$colVorgang] ?? ''),
'buchungstext' => trim($cells[$colBuchungstext] ?? ''),
'wert' => $wert,
'raw_line' => implode($format['delimiter'], $cells),
];
}
return $result;
}
private function readCsv(string $filePath, string $delimiter, string $enclosure): array
{
$content = file_get_contents($filePath);
if ($content === false) {
throw new \RuntimeException('Datei konnte nicht gelesen werden.');
}
// BOM entfernen
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content);
// Verschiedene Zeilenenden normalisieren
$content = str_replace(["\r\n", "\r"], "\n", $content);
$lines = explode("\n", $content);
$result = [];
foreach ($lines as $line) {
if (trim($line) === '') {
$result[] = [];
continue;
}
$result[] = $this->parseCsvLine($line, $delimiter, $enclosure);
}
return $result;
}
private function parseCsvLine(string $line, string $delimiter, string $enclosure): array
{
$cells = [];
$len = strlen($line);
$i = 0;
while ($i < $len) {
if ($line[$i] === $enclosure) {
// Quoted cell
$i++;
$cell = '';
while ($i < $len) {
if ($line[$i] === $enclosure) {
if ($i + 1 < $len && $line[$i + 1] === $enclosure) {
$cell .= $enclosure;
$i += 2;
} else {
$i++;
break;
}
} else {
$cell .= $line[$i++];
}
}
$cells[] = $cell;
// Delimiter überspringen
if ($i < $len && $line[$i] === $delimiter) {
$i++;
}
} else {
// Unquoted cell
$end = strpos($line, $delimiter, $i);
if ($end === false) {
$cells[] = substr($line, $i);
break;
}
$cells[] = substr($line, $i, $end - $i);
$i = $end + 1;
}
}
return $cells;
}
private function readXlsx(string $filePath): array
{
if (!class_exists('ZipArchive')) {
throw new \RuntimeException('ZipArchive nicht verfügbar – XLSX kann nicht gelesen werden.');
}
$zip = new \ZipArchive();
if ($zip->open($filePath) !== true) {
throw new \RuntimeException('XLSX-Datei konnte nicht geöffnet werden.');
}
// Shared Strings laden (optional)
$sharedStrings = [];
$ssXml = $zip->getFromName('xl/sharedStrings.xml');
if ($ssXml !== false) {
$ss = simplexml_load_string($ssXml);
foreach ($ss->si ?? [] as $si) {
$text = '';
if (isset($si->t)) {
$text = (string)$si->t;
} else {
foreach ($si->r ?? [] as $r) {
$text .= (string)($r->t ?? '');
}
}
$sharedStrings[] = $text;
}
}
// Erstes Worksheet lesen
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if ($sheetXml === false) {
throw new \RuntimeException('Kein Arbeitsblatt in der XLSX-Datei gefunden.');
}
$sheet = simplexml_load_string($sheetXml);
$rows = [];
$maxCol = 0;
foreach ($sheet->sheetData->row ?? [] as $row) {
$rowArr = [];
foreach ($row->c as $cell) {
[$colIdx] = $this->xlsxCellRef((string)$cell['r']);
if ($colIdx > $maxCol) $maxCol = $colIdx;
$type = (string)($cell['t'] ?? '');
$value = (string)($cell->v ?? '');
if ($type === 's') {
$value = $sharedStrings[(int)$value] ?? '';
} elseif ($type === 'inlineStr') {
$value = (string)($cell->is->t ?? '');
}
$rowArr[$colIdx] = $value;
}
// Lücken auffüllen
$filled = [];
for ($c = 0; $c <= $maxCol; $c++) {
$filled[] = $rowArr[$c] ?? '';
}
$rows[] = $filled;
}
return $rows;
}
private function xlsxCellRef(string $ref): array
{
preg_match('/^([A-Z]+)(\d+)$/', $ref, $m);
$col = 0;
foreach (str_split($m[1] ?? 'A') as $char) {
$col = $col * 26 + (ord($char) - ord('A') + 1);
}
return [$col - 1, (int)($m[2] ?? 1) - 1];
}
private function parseDate(string $value, string $format): ?string
{
$value = trim($value);
if ($value === '') return null;
$d = \DateTime::createFromFormat($format, $value);
if (!$d) return null;
return $d->format('Y-m-d');
}
private function parseDecimal(string $value, string $decimal, string $thousands): float
{
$value = trim($value);
// Tausendertrennzeichen entfernen, Dezimaltrennzeichen → Punkt
$value = str_replace($thousands, '', $value);
$value = str_replace($decimal, '.', $value);
$value = preg_replace('/[^\d.\-]/', '', $value);
return (float)$value;
}
}

121
src/Views/bank/index.php Normal file
View file

@ -0,0 +1,121 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
?>
<!-- Filter-Leiste -->
<form method="get" action="/bank" class="card mb-2">
<div class="card-body" style="padding:.6rem 1rem">
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap">
<input type="text" name="search"
placeholder="Suche…"
value="<?= $h($filter['search'] ?? '') ?>"
style="flex:1;min-width:160px;max-width:280px">
<select name="year" style="width:90px">
<option value="">Jahr</option>
<?php for ($y = (int)date('Y'); $y >= (int)date('Y') - 5; $y--): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
<select name="month" style="width:80px">
<option value="">Monat</option>
<?php foreach ($months as $i => $m): ?>
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
</select>
<select name="typ" style="width:110px">
<option value="">Alle</option>
<option value="einnahme"<?= ($filter['typ'] ?? '') === 'einnahme' ? ' selected' : '' ?>>Einnahmen</option>
<option value="ausgabe"<?= ($filter['typ'] ?? '') === 'ausgabe' ? ' selected' : '' ?>>Ausgaben</option>
</select>
<select name="status" style="width:110px">
<option value="offen"<?= ($filter['status'] ?? 'offen') === 'offen' ? ' selected' : '' ?>>Offen</option>
<option value="gebucht"<?= ($filter['status'] ?? '') === 'gebucht' ? ' selected' : '' ?>>Gebucht</option>
<option value="ignoriert"<?= ($filter['status'] ?? '') === 'ignoriert' ? ' selected' : '' ?>>Ignoriert</option>
<option value=""<?= ($filter['status'] ?? 'offen') === '' ? ' selected' : '' ?>>Alle</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>
</div>
</form>
<!-- Tabelle -->
<div class="card">
<div class="card-header">
<div class="card-title">
Bankbuchungen
<?php if ($total > 0): ?>
<span class="text-muted" style="font-weight:normal;font-size:.875rem">(<?= $total ?>)</span>
<?php endif; ?>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:110px">Wertstellung</th>
<th>Buchungstext</th>
<th class="td-right" style="width:130px">Wert</th>
<th style="width:100px">Status</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr>
<td colspan="4" class="text-muted" style="text-align:center;padding:2rem">
Keine Buchungen gefunden.
</td>
</tr>
<?php else: ?>
<?php foreach ($rows as $row):
$wert = (float)$row['wert'];
$isPlus = $wert >= 0;
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
?>
<tr style="cursor:pointer" onclick="window.location='/bank/<?= (int)$row['id'] ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
<td>
<div><?= $h($row['buchungstext']) ?></div>
<?php if ($row['vorgang']): ?>
<div class="text-muted text-sm"><?= $h($row['vorgang']) ?></div>
<?php endif; ?>
</td>
<td class="td-right td-mono" style="color:<?= $color ?>;font-weight:600">
<?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> €
</td>
<td>
<?php
$badgeClass = match($row['status']) {
'gebucht' => 'badge-green',
'ignoriert' => 'badge-gray',
default => 'badge-blue',
};
?>
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($pages > 1): ?>
<div class="card-body" style="padding:.75rem 1rem;display:flex;gap:.5rem;align-items:center">
<?php for ($p = 1; $p <= $pages; $p++): ?>
<?php $q = http_build_query(array_merge($filter, ['page' => $p])); ?>
<a href="/bank?<?= $q ?>" class="btn btn-secondary btn-sm<?= $p === $page ? ' btn-primary' : '' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>

129
src/Views/bank/show.php Normal file
View file

@ -0,0 +1,129 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$wert = (float)$transaction['wert'];
$isPlus = $wert >= 0;
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
?>
<!-- Bankbuchung Info-Bar -->
<div class="card mb-2">
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
<div>
<span class="text-muted text-sm">Wertstellung</span><br>
<strong class="td-mono"><?= $h(View::formatDate($transaction['wertstellung'])) ?></strong>
</div>
<div>
<span class="text-muted text-sm">Buchungstag</span><br>
<span class="td-mono"><?= $h(View::formatDate($transaction['buchungstag'])) ?></span>
</div>
<div>
<span class="text-muted text-sm">Vorgang</span><br>
<span><?= $transaction['vorgang'] ? $h($transaction['vorgang']) : '<span class="text-muted">–</span>' ?></span>
</div>
<div style="flex:1;min-width:200px">
<span class="text-muted text-sm">Buchungstext</span><br>
<span><?= $h($transaction['buchungstext']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Wert</span><br>
<strong style="color:<?= $color ?>;font-size:1.1rem">
<?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> €
</strong>
</div>
<div>
<span class="text-muted text-sm">Status</span><br>
<?php
$badgeClass = match($transaction['status']) {
'gebucht' => 'badge-green',
'ignoriert' => 'badge-gray',
default => 'badge-blue',
};
?>
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($transaction['status'])) ?></span>
</div>
<div style="margin-left:auto;display:flex;gap:.5rem;flex-wrap:wrap">
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/status" style="display:inline">
<input type="hidden" name="status" value="ignoriert">
<button type="submit" class="btn btn-secondary btn-sm">Ignorieren</button>
</form>
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/status" style="display:inline">
<input type="hidden" name="status" value="offen">
<button type="submit" class="btn btn-secondary btn-sm">Wieder öffnen</button>
</form>
<a href="/bank" class="btn btn-secondary btn-sm">← Zurück</a>
</div>
</div>
</div>
<!-- Beleg hochladen -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">Beleg hochladen</div></div>
<div class="card-body">
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/beleg" enctype="multipart/form-data">
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
<div class="form-group" style="margin:0;flex:1;min-width:200px">
<input type="file" name="beleg_file" required
accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
style="display:block">
<div class="form-hint">PDF, PNG, JPEG, TIFF, XML</div>
</div>
<button type="submit" class="btn btn-primary">Hochladen &amp; verknüpfen</button>
</div>
</form>
</div>
</div>
<!-- Offene Belege verknüpfen -->
<div class="card">
<div class="card-header">
<div class="card-title">Offene Belege</div>
<span class="text-muted text-sm"><?= count($offeneBelege) ?> ungebuchte Belege</span>
</div>
<?php if (empty($offeneBelege)): ?>
<div class="card-body text-muted">Keine offenen Belege vorhanden.</div>
<?php else: ?>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Belegnr.</th>
<th>Datum</th>
<th>Typ</th>
<th>Lieferant / Notiz</th>
<th class="td-right">Brutto</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($offeneBelege as $b): ?>
<tr>
<td class="td-mono">
<a href="/belege/<?= (int)$b['id'] ?>" target="_blank"><?= $h($b['beleg_nr']) ?></a>
</td>
<td class="td-mono"><?= $h(View::formatDate($b['beleg_datum'])) ?></td>
<td><span class="badge badge-gray"><?= $h($b['belegtyp']) ?></span></td>
<td>
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
<?php if ($b['notes']): ?>
<span class="text-muted text-sm"> · <?= $h(mb_substr($b['notes'], 0, 40)) ?></span>
<?php endif; ?>
</td>
<td class="td-right td-mono">
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) : '<span class="text-muted">–</span>' ?>
</td>
<td>
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/link" style="display:inline">
<input type="hidden" name="beleg_id" value="<?= (int)$b['id'] ?>">
<button type="submit" class="btn btn-primary btn-sm">Verknüpfen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>

87
src/Views/bank/upload.php Normal file
View file

@ -0,0 +1,87 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:700px">
<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-Auswahl -->
<div class="form-group">
<label for="format_id">Import-Format *</label>
<select id="format_id" name="format_id" required>
<?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">
<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');
function setActive(on) {
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
}
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;
return;
}
status.innerHTML = '<span style="color:var(--color-success)">✓ ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>';
btn.disabled = false;
}
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) {
// 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;
onFile(file);
}
});
input.addEventListener('change', () => onFile(input.files[0]));
})();
</script>

View file

@ -24,6 +24,10 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
Belege
</a>
<a href="/bank" class="<?= navActive('/bank', $path) ?>">
<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>
Bank buchen
</a>
<a href="/journal" class="<?= navActive('/journal', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
Buchungsjournal