Feature: Bank buchen – Import, Liste, Detail, Beleg-Verknüpfung
This commit is contained in:
parent
b28ac191fa
commit
02c642e53e
11 changed files with 1008 additions and 0 deletions
12
Changes.md
12
Changes.md
|
|
@ -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
|
## 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')
|
- `UstvaCalculator`: Kz 83 wird nun als `ust81 + ust89 − Kz66 − Kz61` berechnet (nicht mehr kontobasiert via ustva_kz='83')
|
||||||
|
|
|
||||||
56
migrations/049_bank_tables.sql
Normal file
56
migrations/049_bank_tables.sql
Normal 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;
|
||||||
|
|
@ -26,6 +26,7 @@ use Steuerkram\Controllers\SettingsController;
|
||||||
use Steuerkram\Controllers\LoginController;
|
use Steuerkram\Controllers\LoginController;
|
||||||
use Steuerkram\Controllers\AssetController;
|
use Steuerkram\Controllers\AssetController;
|
||||||
use Steuerkram\Controllers\UserAdminController;
|
use Steuerkram\Controllers\UserAdminController;
|
||||||
|
use Steuerkram\Controllers\BankController;
|
||||||
|
|
||||||
Config::load();
|
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/generate', fn($p) => (new BelegController())->generateVorschlag($p));
|
||||||
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($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 ────────────────────────────────────────────────
|
// ── Buchungen ────────────────────────────────────────────────
|
||||||
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
|
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
|
||||||
$router->get('/journal/export', fn($p) => (new BookingController())->export($p));
|
$router->get('/journal/export', fn($p) => (new BookingController())->export($p));
|
||||||
|
|
|
||||||
203
src/Controllers/BankController.php
Normal file
203
src/Controllers/BankController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Models/BankImportFormat.php
Normal file
21
src/Models/BankImportFormat.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/Models/BankTransaction.php
Normal file
125
src/Models/BankTransaction.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/Services/BankImportParser.php
Normal file
240
src/Services/BankImportParser.php
Normal 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
121
src/Views/bank/index.php
Normal 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
129
src/Views/bank/show.php
Normal 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 & 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
87
src/Views/bank/upload.php
Normal 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>
|
||||||
|
|
@ -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>
|
<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
|
Belege
|
||||||
</a>
|
</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) ?>">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
|
||||||
Buchungsjournal
|
Buchungsjournal
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue