Merge pull request 'Bank-Workflow: Mehrfachzuordnung, Fixes & Metadaten' (#71) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/71
This commit is contained in:
fds2610 2026-05-15 18:22:44 +02:00
commit 66b22b143a
11 changed files with 422 additions and 93 deletions

View file

@ -2,6 +2,18 @@
---
## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen
- Migration 053: Junction-Tabelle `bank_transaction_belege` (bank_tx_id, beleg_id, betrag); `bank_transactions.beleg_id` entfernt; Status-ENUM um `teilweise` erweitert; bestehende Verknüpfungen automatisch migriert
- Neuer Status `teilweise`: wird gesetzt wenn mind. ein Beleg verknüpft ist, aber noch Restbetrag offen bleibt
- Standardfilter in der Bankübersicht zeigt jetzt „Offen & Teilweise"
- `bank/show.php`: Sektion „Verknüpfte Belege" mit Betrag je Beleg und Entfernen-Button; Restsaldo-Anzeige; Betrag-Eingabefeld pro Beleg in der Offene-Belege-Liste
- Beim Verknüpfen wird der zugeordnete Betrag an die Buchungsmaske weitergegeben (`alloc_betrag`)
- `BankTransaction::linkBeleg()` schreibt jetzt in Junction-Tabelle mit Betrag; `recalcStatus()` setzt Status automatisch; `unlinkBeleg()` entfernt Verknüpfung; `getLinkedBelege()` / `getRemainingBetrag()` neu
- Neue Route `POST /bank/{id}/unlink`
---
## 2026-05-15 – Feature: Dublettenprüfung beim Bank-Import
- Nach dem Upload wird geprüft, welche Buchungen bereits in der DB vorhanden sind (Abgleich über Buchungstag, Wertstellung, Betrag, Buchungstext)

View file

@ -0,0 +1,24 @@
-- n:m-Verknüpfung Bankbuchung <-> Belege mit zugewiesenem Betrag
CREATE TABLE IF NOT EXISTS bank_transaction_belege (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
bank_tx_id INT UNSIGNED NOT NULL,
beleg_id INT UNSIGNED NOT NULL,
betrag DECIMAL(12,2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_tx_beleg (bank_tx_id, beleg_id),
FOREIGN KEY (bank_tx_id) REFERENCES bank_transactions(id) ON DELETE CASCADE,
FOREIGN KEY (beleg_id) REFERENCES belege(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Bestehende 1:1-Verknüpfungen migrieren
INSERT IGNORE INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
SELECT bt.id, bt.beleg_id, ABS(bt.wert)
FROM bank_transactions bt
WHERE bt.beleg_id IS NOT NULL;
-- Neuer Status 'teilweise'
ALTER TABLE bank_transactions
MODIFY COLUMN status ENUM('offen','teilweise','gebucht','ignoriert') NOT NULL DEFAULT 'offen';
-- Altes Einzelfeld entfernen
ALTER TABLE bank_transactions DROP COLUMN beleg_id;

View file

@ -72,6 +72,7 @@ $router->post('/bank/upload/confirm', fn($p) => (new BankController())->up
$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}/unlink', fn($p) => (new BankController())->unlinkBeleg($p));
$router->post('/bank/{id}/beleg', fn($p) => (new BankController())->uploadBeleg($p));
// ── Buchungen ────────────────────────────────────────────────

View file

@ -19,7 +19,7 @@ class BankController
{
$filter = $_GET;
if (!isset($filter['status'])) {
$filter['status'] = 'offen';
$filter['status'] = 'pending';
}
$page = max(1, (int)($_GET['page'] ?? 1));
@ -159,15 +159,18 @@ class BankController
View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
}
// Offene Belege (noch nicht gebucht)
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200);
$linkedBelege = BankTransaction::getLinkedBelege($id);
$remainingBetrag = BankTransaction::getRemainingBetrag($id);
$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'],
'transaction' => $transaction,
'linkedBelege' => $linkedBelege,
'remainingBetrag' => $remainingBetrag,
'offeneBelege' => $offeneBelege['rows'],
]),
]);
}
@ -177,7 +180,7 @@ class BankController
$id = (int)($params['id'] ?? 0);
$status = $_POST['status'] ?? '';
if (!in_array($status, ['offen', 'gebucht', 'ignoriert'], true)) {
if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) {
View::redirect('/bank', 'Ungültiger Status.', 'error');
}
@ -190,13 +193,28 @@ class BankController
{
$id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0);
$betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2);
if (!$belegId) {
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
}
if ($betrag <= 0) {
View::redirect("/bank/$id", 'Bitte einen gültigen Betrag eingeben.', 'error');
}
BankTransaction::linkBeleg($id, $belegId);
View::redirect('/bank', 'Beleg verknüpft und Buchung als gebucht markiert.');
View::redirect("/belege/$belegId?bank_tx=$id&alloc_betrag=$betrag");
}
public function unlinkBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0);
$redirectTo = $_POST['redirect_to'] ?? '';
BankTransaction::unlinkBeleg($id, $belegId);
$safe = (preg_match('#^/[a-zA-Z0-9/_-]#', $redirectTo)) ? $redirectTo : "/bank/$id";
View::redirect($safe, 'Beleg-Verknüpfung entfernt.');
}
public function uploadBeleg(array $params): void
@ -228,28 +246,29 @@ class BankController
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);
}
$ext = $allowedMime[$mime];
$belegNr = Beleg::generateBelegNr();
$year = date('Y', strtotime($transaction['wertstellung']));
$relDir = "static/uploads/{$year}/{$belegNr}";
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
$storagePath = "{$relDir}/original.{$ext}";
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
move_uploaded_file($file['tmp_name'], $absPath);
$hash = hash_file('sha256', $absPath);
$belegId = Beleg::create([
'beleg_nr' => Beleg::generateBelegNr(),
'beleg_nr' => $belegNr,
'filename' => $file['name'],
'storage_path' => 'storage/belege/' . $filename,
'storage_path' => $storagePath,
'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.');
View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.');
}
private function renderView(string $name, array $data = []): string

View file

@ -223,12 +223,15 @@ class BelegController
if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) {
$bankTxSupplier = trim($m[1]);
}
$bankTxBrutto = abs((float)$bankTx['wert']);
// alloc_betrag aus URL hat Vorrang (Teilbetrag bei Aufteilung), sonst voller Betrag
$allocBetrag = isset($_GET['alloc_betrag']) ? round((float)$_GET['alloc_betrag'], 2) : 0.0;
$bankTxBrutto = $allocBetrag > 0 ? $allocBetrag : abs((float)$bankTx['wert']);
$netto = round($bankTxBrutto / 1.19, 2);
$bankFormData = [
'buchungs_datum' => $bankTx['wertstellung'],
'betrag_netto' => number_format($netto, 2, '.', ''),
'alloc_betrag' => number_format($bankTxBrutto, 2, '.', ''),
'zahlungs_kontonummer'=> '1200',
];

View file

@ -183,10 +183,21 @@ class BookingController
}
}
// Belegbetrag in Metadaten aktualisieren
$totalBrutto = Booking::sumBruttoForBeleg($belegId);
\Steuerkram\Database::execute(
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
[$totalBrutto, $belegId]
);
// Bankbuchung verknüpfen wenn aus Bank-Kontext
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
if ($bankTxId > 0) {
BankTransaction::linkBeleg($bankTxId, $belegId);
// Tatsächlich gebuchten Bruttobetrag aus Buchungsdaten berechnen
$netto = abs((float)$data['betrag_netto']);
$ustSatz = (float)($data['ust_satz'] ?? 0);
$brutto = round($netto * (1 + $ustSatz / 100), 2);
BankTransaction::linkBeleg($bankTxId, $belegId, $brutto);
}
if ($newAssetId !== null) {
@ -210,19 +221,29 @@ class BookingController
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
$assets = \Steuerkram\Models\Asset::all();
$assets = \Steuerkram\Models\Asset::all();
$linked = BankTransaction::findByBelegId($belegId);
// Bank-Kontext aus POST-Daten wiederherstellen (damit alloc_betrag nach Validierungsfehler erhalten bleibt)
$bankTx = null;
if (!empty($formData['bank_tx_id'])) {
$bankTx = BankTransaction::find((int)$formData['bank_tx_id']);
}
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . ($beleg['beleg_nr'] ?? ''),
'content' => $this->renderView('belege/detail', [
'beleg' => $beleg,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
'assets' => $assets,
'formErrors' => $errors,
'formData' => $formData,
'beleg' => $beleg,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
'assets' => $assets,
'formErrors' => $errors,
'formData' => $formData,
'bankTx' => $bankTx,
'bankTxBrutto' => $bankTx ? (float)($formData['alloc_betrag'] ?: abs((float)$bankTx['wert'])) : 0.0,
'linkedBankTx' => $linked,
]),
]);
}

View file

@ -13,8 +13,10 @@ class BankTransaction
$where = ['1=1'];
$params = [];
$status = $filter['status'] ?? 'offen';
if ($status !== '') {
$status = $filter['status'] ?? 'pending';
if ($status === 'pending') {
$where[] = "bt.status IN ('offen','teilweise')";
} elseif ($status !== '') {
$where[] = 'bt.status = ?';
$params[] = $status;
}
@ -51,11 +53,17 @@ class BankTransaction
$rows = Database::fetchAll(
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name,
b.id AS beleg_id, b.beleg_nr
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 belege b ON b.id = bt.beleg_id
LEFT JOIN (
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
FROM bank_transaction_belege
GROUP BY bank_tx_id
) 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
LIMIT ? OFFSET ?",
@ -90,17 +98,105 @@ class BankTransaction
);
}
public static function linkBeleg(int $id, int $belegId): void
/** Verknüpft einen Beleg mit zugeordnetem Betrag und aktualisiert den Status. */
public static function linkBeleg(int $txId, int $belegId, float $betrag): void
{
Database::execute(
"UPDATE bank_transactions SET beleg_id = ?, status = 'gebucht' WHERE id = ?",
[$belegId, $id]
'INSERT INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE betrag = VALUES(betrag)',
[$txId, $belegId, $betrag]
);
self::recalcStatus($txId);
}
/** Entfernt eine Beleg-Verknüpfung und aktualisiert den Status. */
public static function unlinkBeleg(int $txId, int $belegId): void
{
Database::execute(
'DELETE FROM bank_transaction_belege WHERE bank_tx_id = ? AND beleg_id = ?',
[$txId, $belegId]
);
self::recalcStatus($txId);
}
/** Berechnet Status neu: 0 Links→offen, Restsaldo>0→teilweise, Restsaldo=0→gebucht. */
public static function recalcStatus(int $txId): void
{
$row = Database::fetchOne(
'SELECT ABS(bt.wert) AS brutto, COALESCE(SUM(btb.betrag), 0) AS allocated
FROM bank_transactions bt
LEFT JOIN bank_transaction_belege btb ON btb.bank_tx_id = bt.id
WHERE bt.id = ?
GROUP BY bt.id',
[$txId]
);
if (!$row) return;
$remaining = round((float)$row['brutto'] - (float)$row['allocated'], 2);
$allocated = (float)$row['allocated'];
if ($allocated <= 0) {
$status = 'offen';
} elseif ($remaining > 0.01) {
$status = 'teilweise';
} else {
$status = 'gebucht';
}
Database::execute(
"UPDATE bank_transactions SET status = ? WHERE id = ?",
[$status, $txId]
);
}
/** Gibt alle verknüpften Belege mit ihren Beträgen zurück. */
public static function getLinkedBelege(int $txId): array
{
return Database::fetchAll(
'SELECT btb.betrag, b.id AS beleg_id, b.beleg_nr, b.beleg_datum,
b.belegtyp, b.betrag_brutto, s.name AS supplier_name
FROM bank_transaction_belege btb
JOIN belege b ON b.id = btb.beleg_id
LEFT JOIN suppliers s ON s.id = b.supplier_id
WHERE btb.bank_tx_id = ?
ORDER BY b.beleg_nr',
[$txId]
);
}
/** Gibt den noch nicht zugeordneten Restbetrag zurück. */
public static function getRemainingBetrag(int $txId): float
{
$row = Database::fetchOne(
'SELECT ABS(bt.wert) AS brutto, COALESCE(SUM(btb.betrag), 0) AS allocated
FROM bank_transactions bt
LEFT JOIN bank_transaction_belege btb ON btb.bank_tx_id = bt.id
WHERE bt.id = ?
GROUP BY bt.id',
[$txId]
);
if (!$row) return 0.0;
return round(max(0.0, (float)$row['brutto'] - (float)$row['allocated']), 2);
}
/** Sucht die Bankbuchung, die mit einem bestimmten Beleg verknüpft ist. */
public static function findByBelegId(int $belegId): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, 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
WHERE btb.beleg_id = ?
LIMIT 1',
[$belegId]
);
return $row ?: null;
}
/**
* Teilt $rows in ['new' => [...], 'dup' => [...]] auf.
* Eine Zeile gilt als Dublette, wenn buchungstag + wertstellung + wert + buchungstext(200) bereits existieren.
*/
public static function findDuplicates(array $rows): array
{
@ -108,7 +204,6 @@ class BankTransaction
return ['new' => [], 'dup' => []];
}
// Kandidaten-Abfrage: alle DB-Zeilen, deren (buchungstag, wertstellung, wert) in den Upload-Daten vorkommen
$placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)'));
$params = [];
foreach ($rows as $r) {
@ -124,7 +219,6 @@ class BankTransaction
$params
);
// Lookup-Set für schnellen Vergleich
$lookup = [];
foreach ($existing as $e) {
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
@ -147,19 +241,6 @@ class BankTransaction
return ['new' => $new, 'dup' => $dup];
}
public static function findByBelegId(int $belegId): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename
FROM bank_transactions bt
JOIN bank_import_batches bib ON bib.id = bt.batch_id
WHERE bt.beleg_id = ?
LIMIT 1',
[$belegId]
);
return $row ?: null;
}
public static function createBatch(int $formatId, string $filename, int $rowCount): int
{
Database::execute(

View file

@ -407,6 +407,18 @@ class Booking
return sprintf('BU-%s-%06d', $year, $next);
}
/** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs. */
public static function sumBruttoForBeleg(int $belegId): float
{
$row = Database::fetchOne(
'SELECT COALESCE(SUM(betrag_brutto), 0) AS total
FROM bookings
WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL',
[$belegId]
);
return round((float)($row['total'] ?? 0), 2);
}
public static function sumByYear(int $year): array
{
return Database::fetchAll(

View file

@ -36,11 +36,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<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>
<select name="status" style="width:140px">
<option value="pending"<?= ($filter['status'] ?? 'pending') === 'pending' ? ' selected' : '' ?>>Offen &amp; Teilweise</option>
<option value="offen"<?= ($filter['status'] ?? '') === 'offen' ? ' selected' : '' ?>>Nur Offen</option>
<option value="teilweise"<?= ($filter['status'] ?? '') === 'teilweise' ? ' selected' : '' ?>>Teilweise</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>
<option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
@ -82,9 +84,10 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
$wert = (float)$row['wert'];
$isPlus = $wert >= 0;
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
$ziel = ($row['status'] === 'gebucht' && !empty($row['beleg_id']))
? '/belege/' . (int)$row['beleg_id']
: '/bank/' . (int)$row['id'];
$ziel = '/bank/' . (int)$row['id'];
if ($row['status'] === 'gebucht' && !empty($row['beleg_id']) && ($row['beleg_count'] ?? 1) == 1) {
$ziel = '/belege/' . (int)$row['beleg_id'];
}
?>
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
@ -101,13 +104,20 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<?php
$badgeClass = match($row['status']) {
'gebucht' => 'badge-green',
'teilweise' => 'badge-warning',
'ignoriert' => 'badge-gray',
default => 'badge-blue',
};
?>
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
<?php if ($row['status'] === 'gebucht' && !empty($row['beleg_nr'])): ?>
<div class="text-muted text-sm" style="margin-top:.25rem"><?= $h($row['beleg_nr']) ?></div>
<?php if (in_array($row['status'], ['gebucht','teilweise']) && !empty($row['beleg_nr'])): ?>
<div class="text-muted text-sm" style="margin-top:.25rem">
<?php if (($row['beleg_count'] ?? 1) > 1): ?>
<?= (int)$row['beleg_count'] ?> Belege
<?php else: ?>
<?= $h($row['beleg_nr']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td onclick="event.stopPropagation()">

View file

@ -11,6 +11,10 @@ $color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
$settings = Setting::get();
$vorschauBreite = (int)($settings['beleg_vorschau_breite'] ?? 200);
$vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
$remaining = (float)$remainingBetrag;
$totalBrutto = abs($wert);
$restFormatted = number_format($remaining, 2, ',', '.');
?>
<!-- Bankbuchung Info-Bar -->
@ -33,16 +37,23 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<span><?= $h($transaction['buchungstext']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Wert</span><br>
<span class="text-muted text-sm">Gesamt</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">Verbleibend</span><br>
<strong style="color:<?= $remaining <= 0.01 ? 'var(--color-success)' : 'var(--color-warning,#d97706)' ?>;font-size:1.1rem">
<?= $h($restFormatted) ?> €
</strong>
</div>
<div>
<span class="text-muted text-sm">Status</span><br>
<?php
$badgeClass = match($transaction['status']) {
'gebucht' => 'badge-green',
'teilweise' => 'badge-warning',
'ignoriert' => 'badge-gray',
default => 'badge-blue',
};
@ -63,24 +74,133 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
</div>
</div>
<?php if (!empty($linkedBelege)): ?>
<!-- Bereits verknüpfte Belege -->
<div class="card mb-2">
<div class="card-header">
<div class="card-title">Verknüpfte Belege</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Belegnr.</th>
<th>Datum</th>
<th>Typ</th>
<th>Lieferant</th>
<th class="td-right">Belegbetrag</th>
<th class="td-right">Zugeordnet</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($linkedBelege as $lb): ?>
<tr>
<td class="td-mono">
<a href="/belege/<?= (int)$lb['beleg_id'] ?>"><?= $h($lb['beleg_nr']) ?></a>
</td>
<td class="td-mono"><?= $h(View::formatDate($lb['beleg_datum'])) ?></td>
<td><span class="badge badge-gray"><?= $h($lb['belegtyp']) ?></span></td>
<td><?= $lb['supplier_name'] ? $h($lb['supplier_name']) : '<span class="text-muted">–</span>' ?></td>
<td class="td-right td-mono">
<?= $lb['betrag_brutto'] ? $fmt($lb['betrag_brutto']) . ' €' : '<span class="text-muted">–</span>' ?>
</td>
<td class="td-right td-mono" style="font-weight:600">
<?= $h(number_format((float)$lb['betrag'], 2, ',', '.')) ?> €
</td>
<td>
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/unlink"
data-confirm="Verknüpfung mit <?= $h($lb['beleg_nr']) ?> wirklich entfernen?">
<input type="hidden" name="beleg_id" value="<?= (int)$lb['beleg_id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">Entfernen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if ($remaining > 0.01): ?>
<!-- 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 class="card-header"><div class="card-title">Neuen Beleg hochladen</div></div>
<div class="card-body" style="padding-bottom:1rem">
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/beleg"
enctype="multipart/form-data" id="beleg-upload-form">
<div id="beleg-dnd-zone" style="
border:2px dashed var(--color-border);
border-radius:var(--radius);
background:var(--color-surface);
padding:1.5rem 2rem;
text-align:center;
cursor:pointer;
transition:border-color .15s,background .15s;
position:relative;
">
<input type="file" name="beleg_file" id="beleg-dnd-input" required
accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
<div style="pointer-events:none">
<div style="font-size:1.5rem;line-height:1;margin-bottom:.4rem">📄</div>
<div style="font-weight:600">Beleg hierher ziehen oder klicken</div>
<div class="text-muted text-sm" style="margin-top:.25rem">PDF · PNG · JPEG · TIFF · XML</div>
<div id="beleg-dnd-status" style="margin-top:.5rem;min-height:1.25rem"></div>
</div>
<button type="submit" class="btn btn-primary">Hochladen &amp; verknüpfen</button>
</div>
<div style="margin-top:.75rem;display:flex;justify-content:flex-end">
<button type="submit" class="btn btn-primary" id="beleg-upload-btn" disabled>
Hochladen
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
const zone = document.getElementById('beleg-dnd-zone');
const input = document.getElementById('beleg-dnd-input');
const status = document.getElementById('beleg-dnd-status');
const btn = document.getElementById('beleg-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 (!['pdf','png','jpg','jpeg','tif','tiff','xml'].includes(ext)) {
status.innerHTML = '<span style="color:var(--color-danger)">Nicht unterstütztes Format.</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) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
onFile(file);
}
});
input.addEventListener('change', () => onFile(input.files[0]));
})();
</script>
<!-- Offene Belege + Vorschau nebeneinander -->
<div style="display:flex;gap:1rem;align-items:flex-start">
@ -102,6 +222,7 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<th>Typ</th>
<th>Lieferant</th>
<th class="td-right">Brutto</th>
<th class="td-right" style="width:130px">Zuzuordnen (€)</th>
<th></th>
</tr>
</thead>
@ -120,11 +241,19 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
</td>
<td class="td-right td-mono">
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) : '<span class="text-muted">–</span>' ?>
<?= $b['betrag_brutto'] ? $fmt($b['betrag_brutto']) . ' €' : '<span class="text-muted">–</span>' ?>
</td>
<td onclick="event.stopPropagation()">
<a href="/belege/<?= (int)$b['id'] ?>?bank_tx=<?= (int)$transaction['id'] ?>"
class="btn btn-primary btn-sm">Verknüpfen</a>
<td class="td-right" onclick="event.stopPropagation()">
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/link"
style="display:flex;gap:.4rem;align-items:center;justify-content:flex-end">
<input type="hidden" name="beleg_id" value="<?= (int)$b['id'] ?>">
<input type="number" name="alloc_betrag"
value="<?= $h(number_format($remaining, 2, '.', '')) ?>"
min="0.01" step="0.01"
style="width:90px;text-align:right"
required>
<button type="submit" class="btn btn-primary btn-sm">Verknüpfen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
@ -168,12 +297,10 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
const id = row.dataset.belegId;
if (!id) return;
// Hervorhebung wechseln
if (activeRow) activeRow.style.background = '';
activeRow = row;
row.style.background = 'var(--color-primary-light, #eff6ff)';
// Vorschau laden
hint.style.display = 'none';
frame.style.display = 'block';
frame.src = '/belege/' + id + '/file';
@ -181,3 +308,4 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
});
})();
</script>
<?php endif; ?>

View file

@ -298,23 +298,35 @@ $suppliers = $suppliers ?? [];
<!-- Gespeicherte Bankbuchungs-Verknüpfung -->
<?php if ($linkedBankTx ?? null): ?>
<?php $lbt = $linkedBankTx; $lbwert = (float)$lbt['wert']; ?>
<a href="/bank/<?= (int)$lbt['id'] ?>" class="card mb-2"
style="display:block;text-decoration:none;color:inherit;border-left:4px solid var(--color-success)">
<div class="card mb-2" style="border-left:4px solid var(--color-success)">
<div class="card-body" style="padding:.6rem 1.25rem">
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<span class="badge badge-green" style="font-size:.8rem">Bankbuchung</span>
<span class="td-mono text-sm"><?= View::formatDate($lbt['wertstellung']) ?></span>
<?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"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
</strong>
<a href="/bank/<?= (int)$lbt['id'] ?>" style="display:contents;text-decoration:none;color:inherit">
<span class="td-mono text-sm"><?= View::formatDate($lbt['wertstellung']) ?></span>
<?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"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></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)): ?>
<span class="text-muted" style="font-weight:normal;font-size:.85em">
(zugeordnet: <?= number_format((float)$lbt['alloc_betrag'], 2, ',', '.') ?> €)
</span>
<?php endif; ?>
</strong>
</a>
<form method="post" action="/bank/<?= (int)$lbt['id'] ?>/unlink"
data-confirm="Verknüpfung mit dieser Bankbuchung wirklich aufheben?">
<input type="hidden" name="beleg_id" value="<?= (int)$beleg['id'] ?>">
<input type="hidden" name="redirect_to" value="/belege/<?= (int)$beleg['id'] ?>">
<button type="submit" class="btn btn-secondary btn-sm">Verknüpfung aufheben</button>
</form>
</div>
</div>
</a>
</div>
<?php endif; ?>
<!-- BankBuchung-Badge (ersetzt KI-Badge wenn aus Bank-Kontext) -->
@ -375,8 +387,14 @@ $suppliers = $suppliers ?? [];
</div>
<?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<?php if ($bankTx ?? null): ?>
<input type="hidden" name="bank_tx_id" value="<?= (int)$bankTx['id'] ?>">
<?php
// bank_tx_id und alloc_betrag aus aktiver Session ODER aus POST-Daten beim Re-Render nach Validierungsfehler
$effectiveBankTxId = ($bankTx ?? null) ? (int)$bankTx['id'] : (int)($formData['bank_tx_id'] ?? 0);
$effectiveAllocBetrag = $formData['alloc_betrag'] ?? '';
?>
<?php if ($effectiveBankTxId > 0): ?>
<input type="hidden" name="bank_tx_id" value="<?= $effectiveBankTxId ?>">
<input type="hidden" name="alloc_betrag" value="<?= $h($effectiveAllocBetrag) ?>">
<?php endif; ?>
<!-- Buchungstyp -->