Feature: Bankbuchung auf mehrere Belege aufteilen mit Restsaldo und Status teilweise

This commit is contained in:
fds2610-bot 2026-05-15 17:30:31 +02:00
parent ce7a64534b
commit 702b0e4664
10 changed files with 273 additions and 52 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 ## 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) - 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->get('/bank/{id}', fn($p) => (new BankController())->show($p));
$router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($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}/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)); $router->post('/bank/{id}/beleg', fn($p) => (new BankController())->uploadBeleg($p));
// ── Buchungen ──────────────────────────────────────────────── // ── Buchungen ────────────────────────────────────────────────

View file

@ -19,7 +19,7 @@ class BankController
{ {
$filter = $_GET; $filter = $_GET;
if (!isset($filter['status'])) { if (!isset($filter['status'])) {
$filter['status'] = 'offen'; $filter['status'] = 'pending';
} }
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
@ -159,15 +159,18 @@ class BankController
View::redirect('/bank', 'Buchung nicht gefunden.', 'error'); View::redirect('/bank', 'Buchung nicht gefunden.', 'error');
} }
// Offene Belege (noch nicht gebucht) $linkedBelege = BankTransaction::getLinkedBelege($id);
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200); $remainingBetrag = BankTransaction::getRemainingBetrag($id);
$offeneBelege = Beleg::all(['booked' => '0'], 1, 200);
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Bankbuchung zuordnen', 'pageTitle' => 'Bankbuchung zuordnen',
'content' => $this->renderView('bank/show', [ 'content' => $this->renderView('bank/show', [
'transaction' => $transaction, 'transaction' => $transaction,
'offeneBelege' => $offeneBelege['rows'], 'linkedBelege' => $linkedBelege,
'remainingBetrag' => $remainingBetrag,
'offeneBelege' => $offeneBelege['rows'],
]), ]),
]); ]);
} }
@ -177,7 +180,7 @@ class BankController
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$status = $_POST['status'] ?? ''; $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'); View::redirect('/bank', 'Ungültiger Status.', 'error');
} }
@ -190,13 +193,25 @@ class BankController
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0); $belegId = (int)($_POST['beleg_id'] ?? 0);
$betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2);
if (!$belegId) { if (!$belegId) {
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error'); 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("/belege/$belegId?bank_tx=$id&alloc_betrag=$betrag");
View::redirect('/bank', 'Beleg verknüpft und Buchung als gebucht markiert.'); }
public function unlinkBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0);
BankTransaction::unlinkBeleg($id, $belegId);
View::redirect("/bank/$id", 'Beleg-Verknüpfung entfernt.');
} }
public function uploadBeleg(array $params): void public function uploadBeleg(array $params): void
@ -248,8 +263,9 @@ class BankController
'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung', 'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung',
]); ]);
BankTransaction::linkBeleg($id, $belegId); $betrag = BankTransaction::getRemainingBetrag($id) ?: abs((float)$transaction['wert']);
View::redirect('/bank', 'Beleg hochgeladen und Buchung als gebucht markiert.'); BankTransaction::linkBeleg($id, $belegId, $betrag);
View::redirect('/bank', 'Beleg hochgeladen und Buchung verknüpft.');
} }
private function renderView(string $name, array $data = []): string 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)) { if (preg_match('/Auftraggeber:\s*(.*?)(?:\s*Buchungstext|\s*$)/si', $bankTx['buchungstext'], $m)) {
$bankTxSupplier = trim($m[1]); $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); $netto = round($bankTxBrutto / 1.19, 2);
$bankFormData = [ $bankFormData = [
'buchungs_datum' => $bankTx['wertstellung'], 'buchungs_datum' => $bankTx['wertstellung'],
'betrag_netto' => number_format($netto, 2, '.', ''), 'betrag_netto' => number_format($netto, 2, '.', ''),
'alloc_betrag' => number_format($bankTxBrutto, 2, '.', ''),
'zahlungs_kontonummer'=> '1200', 'zahlungs_kontonummer'=> '1200',
]; ];

View file

@ -184,9 +184,13 @@ class BookingController
} }
// Bankbuchung verknüpfen wenn aus Bank-Kontext // Bankbuchung verknüpfen wenn aus Bank-Kontext
$bankTxId = (int)($data['bank_tx_id'] ?? 0); $bankTxId = (int)($data['bank_tx_id'] ?? 0);
$allocBetrag = round((float)str_replace(',', '.', $data['alloc_betrag'] ?? ''), 2);
if ($bankTxId > 0) { if ($bankTxId > 0) {
BankTransaction::linkBeleg($bankTxId, $belegId); if ($allocBetrag <= 0) {
$allocBetrag = BankTransaction::getRemainingBetrag($bankTxId) ?: abs((float)$data['betrag_netto'] ?? 0);
}
BankTransaction::linkBeleg($bankTxId, $belegId, $allocBetrag);
} }
if ($newAssetId !== null) { if ($newAssetId !== null) {

View file

@ -13,8 +13,10 @@ class BankTransaction
$where = ['1=1']; $where = ['1=1'];
$params = []; $params = [];
$status = $filter['status'] ?? 'offen'; $status = $filter['status'] ?? 'pending';
if ($status !== '') { if ($status === 'pending') {
$where[] = "bt.status IN ('offen','teilweise')";
} elseif ($status !== '') {
$where[] = 'bt.status = ?'; $where[] = 'bt.status = ?';
$params[] = $status; $params[] = $status;
} }
@ -51,11 +53,17 @@ class BankTransaction
$rows = Database::fetchAll( $rows = Database::fetchAll(
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, "SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name,
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 FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id JOIN bank_import_batches bib ON bt.batch_id = bib.id
JOIN bank_import_formats bif ON bib.format_id = bif.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 WHERE $whereStr
ORDER BY bt.wertstellung DESC, bt.id DESC ORDER BY bt.wertstellung DESC, bt.id DESC
LIMIT ? OFFSET ?", 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( Database::execute(
"UPDATE bank_transactions SET beleg_id = ?, status = 'gebucht' WHERE id = ?", 'INSERT INTO bank_transaction_belege (bank_tx_id, beleg_id, betrag)
[$belegId, $id] 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. * 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 public static function findDuplicates(array $rows): array
{ {
@ -108,7 +204,6 @@ class BankTransaction
return ['new' => [], 'dup' => []]; return ['new' => [], 'dup' => []];
} }
// Kandidaten-Abfrage: alle DB-Zeilen, deren (buchungstag, wertstellung, wert) in den Upload-Daten vorkommen
$placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)')); $placeholders = implode(',', array_fill(0, count($rows), '(?,?,?)'));
$params = []; $params = [];
foreach ($rows as $r) { foreach ($rows as $r) {
@ -124,7 +219,6 @@ class BankTransaction
$params $params
); );
// Lookup-Set für schnellen Vergleich
$lookup = []; $lookup = [];
foreach ($existing as $e) { foreach ($existing as $e) {
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true; $lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true;
@ -147,19 +241,6 @@ class BankTransaction
return ['new' => $new, 'dup' => $dup]; 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 public static function createBatch(int $formatId, string $filename, int $rowCount): int
{ {
Database::execute( Database::execute(

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> <option value="ausgabe"<?= ($filter['typ'] ?? '') === 'ausgabe' ? ' selected' : '' ?>>Ausgaben</option>
</select> </select>
<select name="status" style="width:110px"> <select name="status" style="width:140px">
<option value="offen"<?= ($filter['status'] ?? 'offen') === 'offen' ? ' selected' : '' ?>>Offen</option> <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="gebucht"<?= ($filter['status'] ?? '') === 'gebucht' ? ' selected' : '' ?>>Gebucht</option>
<option value="ignoriert"<?= ($filter['status'] ?? '') === 'ignoriert' ? ' selected' : '' ?>>Ignoriert</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> </select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button> <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']; $wert = (float)$row['wert'];
$isPlus = $wert >= 0; $isPlus = $wert >= 0;
$color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)'; $color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
$ziel = ($row['status'] === 'gebucht' && !empty($row['beleg_id'])) $ziel = '/bank/' . (int)$row['id'];
? '/belege/' . (int)$row['beleg_id'] if ($row['status'] === 'gebucht' && !empty($row['beleg_id']) && ($row['beleg_count'] ?? 1) == 1) {
: '/bank/' . (int)$row['id']; $ziel = '/belege/' . (int)$row['beleg_id'];
}
?> ?>
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'"> <tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td> <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 <?php
$badgeClass = match($row['status']) { $badgeClass = match($row['status']) {
'gebucht' => 'badge-green', 'gebucht' => 'badge-green',
'teilweise' => 'badge-warning',
'ignoriert' => 'badge-gray', 'ignoriert' => 'badge-gray',
default => 'badge-blue', default => 'badge-blue',
}; };
?> ?>
<span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span> <span class="badge <?= $badgeClass ?>"><?= $h(ucfirst($row['status'])) ?></span>
<?php if ($row['status'] === 'gebucht' && !empty($row['beleg_nr'])): ?> <?php if (in_array($row['status'], ['gebucht','teilweise']) && !empty($row['beleg_nr'])): ?>
<div class="text-muted text-sm" style="margin-top:.25rem"><?= $h($row['beleg_nr']) ?></div> <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; ?> <?php endif; ?>
</td> </td>
<td onclick="event.stopPropagation()"> <td onclick="event.stopPropagation()">

View file

@ -11,6 +11,10 @@ $color = $isPlus ? 'var(--color-success)' : 'var(--color-danger)';
$settings = Setting::get(); $settings = Setting::get();
$vorschauBreite = (int)($settings['beleg_vorschau_breite'] ?? 200); $vorschauBreite = (int)($settings['beleg_vorschau_breite'] ?? 200);
$vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300); $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
$remaining = (float)$remainingBetrag;
$totalBrutto = abs($wert);
$restFormatted = number_format($remaining, 2, ',', '.');
?> ?>
<!-- Bankbuchung Info-Bar --> <!-- Bankbuchung Info-Bar -->
@ -33,16 +37,23 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<span><?= $h($transaction['buchungstext']) ?></span> <span><?= $h($transaction['buchungstext']) ?></span>
</div> </div>
<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"> <strong style="color:<?= $color ?>;font-size:1.1rem">
<?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> € <?= $isPlus ? '+' : '' ?><?= $h(number_format($wert, 2, ',', '.')) ?> €
</strong> </strong>
</div> </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> <div>
<span class="text-muted text-sm">Status</span><br> <span class="text-muted text-sm">Status</span><br>
<?php <?php
$badgeClass = match($transaction['status']) { $badgeClass = match($transaction['status']) {
'gebucht' => 'badge-green', 'gebucht' => 'badge-green',
'teilweise' => 'badge-warning',
'ignoriert' => 'badge-gray', 'ignoriert' => 'badge-gray',
default => 'badge-blue', default => 'badge-blue',
}; };
@ -63,9 +74,59 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
</div> </div>
</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 --> <!-- Beleg hochladen -->
<div class="card mb-2"> <div class="card mb-2">
<div class="card-header"><div class="card-title">Beleg hochladen</div></div> <div class="card-header"><div class="card-title">Neuen Beleg hochladen &amp; verknüpfen</div></div>
<div class="card-body"> <div class="card-body">
<form method="post" action="/bank/<?= (int)$transaction['id'] ?>/beleg" enctype="multipart/form-data"> <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 style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
@ -102,6 +163,7 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<th>Typ</th> <th>Typ</th>
<th>Lieferant</th> <th>Lieferant</th>
<th class="td-right">Brutto</th> <th class="td-right">Brutto</th>
<th class="td-right" style="width:130px">Zuzuordnen (€)</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -120,11 +182,19 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
<?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?> <?= $b['supplier_name'] ? $h($b['supplier_name']) : '<span class="text-muted">–</span>' ?>
</td> </td>
<td class="td-right td-mono"> <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>
<td onclick="event.stopPropagation()"> <td class="td-right" onclick="event.stopPropagation()">
<a href="/belege/<?= (int)$b['id'] ?>?bank_tx=<?= (int)$transaction['id'] ?>" <form method="post" action="/bank/<?= (int)$transaction['id'] ?>/link"
class="btn btn-primary btn-sm">Verknüpfen</a> 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> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@ -168,12 +238,10 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
const id = row.dataset.belegId; const id = row.dataset.belegId;
if (!id) return; if (!id) return;
// Hervorhebung wechseln
if (activeRow) activeRow.style.background = ''; if (activeRow) activeRow.style.background = '';
activeRow = row; activeRow = row;
row.style.background = 'var(--color-primary-light, #eff6ff)'; row.style.background = 'var(--color-primary-light, #eff6ff)';
// Vorschau laden
hint.style.display = 'none'; hint.style.display = 'none';
frame.style.display = 'block'; frame.style.display = 'block';
frame.src = '/belege/' + id + '/file'; frame.src = '/belege/' + id + '/file';
@ -181,3 +249,4 @@ $vorschauHoehe = (int)($settings['beleg_vorschau_hoehe'] ?? 300);
}); });
})(); })();
</script> </script>
<?php endif; ?>

View file

@ -377,6 +377,7 @@ $suppliers = $suppliers ?? [];
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings"> <form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<?php if ($bankTx ?? null): ?> <?php if ($bankTx ?? null): ?>
<input type="hidden" name="bank_tx_id" value="<?= (int)$bankTx['id'] ?>"> <input type="hidden" name="bank_tx_id" value="<?= (int)$bankTx['id'] ?>">
<input type="hidden" name="alloc_betrag" value="<?= $h($formData['alloc_betrag'] ?? '') ?>">
<?php endif; ?> <?php endif; ?>
<!-- Buchungstyp --> <!-- Buchungstyp -->