Finanzmanager/src/Views/buchungen/kontoblatt.php
fds2610-bot a077e03bee Fix #40: Kategorie im Kontoblatt ohne Neuladen speichern
Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze
Seite neu, um die Zeile zu aktualisieren – dabei sprang der Bildschirm
nach ganz oben. kontoblatt.js schickt die Auswahl jetzt per fetch im
Hintergrund; Kategorie, Klasse und das Symbol der Bargeld-Abgleichspalte
aktualisieren sich an Ort und Stelle, die Bildlaufposition bleibt stehen.
Ohne JavaScript bleibt es bei der bisherigen Weiterleitung.
2026-08-19 14:57:09 +02:00

506 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\View;
/** @var array<string,mixed> $konto */
/** @var list<array<string,mixed>> $zeilen */
/** @var int $gesamt */
/** @var int $seite */
/** @var int $seiten */
/** @var array<string,mixed> $filter */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
/** @var list<array<string,mixed>> $konten */
/** @var array<int, list<array<string,mixed>>> $splits */
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
$kategorieNamen = [];
foreach ($kategorien as $k) {
$kategorieNamen[(int)$k['id']] = $k['pfad'];
}
$klassenNamen = [];
foreach ($klassen as $k) {
$klassenNamen[(int)$k['id']] = $k['name'];
}
$kontenNamen = array_column($konten, 'name', 'id');
$eigenesKonto = (int)$konto['id'];
$gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis'])
|| !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']);
$seitenPfad = static function (int $nr) use ($filter): string {
$p = array_filter([
'suche' => $filter['suche'] ?? '',
'von' => $filter['von'] ?? '',
'bis' => $filter['bis'] ?? '',
'kategorie_id' => $filter['kategorie_id'] ?? '',
'klasse_id' => $filter['klasse_id'] ?? '',
'nur_offene' => !empty($filter['nur_offene']) ? '1' : '',
'seite' => (string)$nr,
], static fn(string $v): bool => $v !== '');
return '?' . http_build_query($p);
};
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
$abfrage = $seitenPfad($seite);
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
/**
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
* Präfixe wie in der Buchungsmaske.
*/
$zuordnungswert = static function (array $zeile): string {
if ((int)($zeile['gesplittet'] ?? 0) === 1) {
return 'split';
}
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
};
/**
* Die Splitzeilen einer Buchung, wie der Dialog sie braucht. Sie hängen als
* JSON an der Zelle statt nachgeladen zu werden: Es sind die Zeilen der wenigen
* aufgeteilten Buchungen dieser Seite, und ein zweiter Weg zu denselben Daten
* wäre auch ein zweiter Weg, sie falsch zu lesen.
*
* @param list<array<string,mixed>> $splitzeilen
*/
$splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string {
return (string)json_encode(array_map(static fn(array $zeile): array => [
'zuordnung' => $zuordnungswert($zeile),
'klasse_id' => (int)($zeile['klasse_id'] ?? 0),
'notiz' => (string)$zeile['notiz'],
'betrag' => View::betrag($zeile['betrag'], false),
], $splitzeilen), JSON_UNESCAPED_UNICODE);
};
/**
* Der Tooltip einer Splitbuchung: je Zeile Kategorie und Betrag. In der Spalte
* steht nur „Splitbuchung“ – wohin das Geld geht, wäre sonst erst im Dialog zu
* sehen. Notiz und Klasse bleiben draußen; sie stünden im Weg.
*
* @param list<array<string,mixed>> $splitzeilen
*/
$splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $kontenNamen): string {
$texte = [];
foreach ($splitzeilen as $zeile) {
$name = match (true) {
!empty($zeile['gegenkonto_id']) => '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']',
!empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? '?'),
default => '(ohne Kategorie)',
};
$texte[] = $name . ': ' . View::betrag($zeile['betrag']);
}
return implode("\n", $texte);
};
/**
* Das Symbol der Abgleichspalte, siehe View::bargeldMarke() – die Schnell-
* zuordnung (kontoblatt.js) braucht dasselbe Symbol für eine einzelne Zeile,
* ohne die Seite neu zu laden.
*
* @param array<string,mixed> $zeile
*/
$bargeldmarke = static fn(array $zeile): string
=> View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<span class="tab aktiv">Kontoblatt</span>
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten" class="tab">Kontodaten</a>
</div>
<div class="seitenaktionen">
<a href="/konten/<?= (int)$konto['id'] ?>/buchung" class="knopf primaer">Neue Buchung</a>
<a href="/konten" class="knopf">Kontenübersicht</a>
<?php if ($zeilen !== []): ?>
<!-- Das Formular steht hier, seine Ankreuzfelder stehen in der Tabelle;
verbunden sind sie über das form-Attribut. Ein Formular um die
ganze Tabelle würde das Filterformular verschachteln. -->
<form method="post" action="<?= View::h($loeschZiel) ?>" id="buchungsauswahl" class="inline"
data-bestaetigen="Die ausgewählten Buchungen wirklich löschen?"
data-frage-eine="Die ausgewählte Buchung wirklich löschen?"
data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken ?? '') ?>">
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
</form>
<?php endif; ?>
<span class="fuellung"></span>
<span class="saldoanzeige">
Aktueller Saldo
<strong class="<?= View::betragKlasse($konto['saldo_aktuell']) ?>">
<?= View::betrag($konto['saldo_aktuell']) ?>
</strong>
</span>
<?php if (abs((float)$konto['saldo_erwartet'] - (float)$konto['saldo_aktuell']) > 0.005): ?>
<span class="saldoanzeige">
Erwartet
<strong class="<?= View::betragKlasse($konto['saldo_erwartet']) ?>">
<?= View::betrag($konto['saldo_erwartet']) ?>
</strong>
</span>
<?php endif; ?>
</div>
<form method="get" action="/konten/<?= (int)$konto['id'] ?>" class="filterleiste">
<input type="search" name="suche" placeholder="Empfänger, Text, Nummer"
value="<?= View::h($filter['suche'] ?? '') ?>">
<label>von <input type="date" name="von" value="<?= View::h($filter['von'] ?? '') ?>"></label>
<label>bis <input type="date" name="bis" value="<?= View::h($filter['bis'] ?? '') ?>"></label>
<select name="kategorie_id">
<option value="">Alle Kategorien</option>
<?php foreach ($kategorien as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($filter['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="klasse_id">
<option value="">Alle Klassen</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($filter['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label class="ankreuzfeld">
<input type="checkbox" name="nur_offene" value="1" <?= !empty($filter['nur_offene']) ? 'checked' : '' ?>>
nur nicht abgeglichene
</label>
<button type="submit" class="knopf klein">Filtern</button>
<?php if ($gefiltert): ?>
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf klein">Filter zurücksetzen</a>
<?php endif; ?>
</form>
<?php if ($zeilen === []): ?>
<p class="platzhalter">
<?= $gefiltert ? 'Keine Buchung passt zum Filter.' : 'Für dieses Konto ist noch nichts gebucht.' ?>
</p>
<?php else: ?>
<table class="liste kontoblatt" data-abfrage="<?= View::h($abfrage) ?>">
<thead>
<tr>
<th class="wahlspalte">
<input type="checkbox" class="alle-waehlen" data-waehlt="buchungsauswahl"
aria-label="Alle angezeigten Buchungen auswählen">
</th>
<th>Datum</th>
<th>Nr.</th>
<th>Empfänger</th>
<th>Buchungstext</th>
<th>Kategorie</th>
<th>Klasse</th>
<?php if ($bargeld): ?>
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
<?php endif; ?>
<th class="rechts">Betrag</th>
<th class="rechts">Saldo</th>
<th>✓</th>
</tr>
</thead>
<tbody>
<?php foreach ($zeilen as $zeile): ?>
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
<td class="wahlspalte">
<?php if (!$zeile['gegensicht']): ?>
<input type="checkbox" name="buchungen[]" value="<?= (int)$zeile['id'] ?>"
form="buchungsauswahl"
aria-label="Buchung vom <?= View::h(View::datum($zeile['datum'])) ?> auswählen">
<?php endif; ?>
</td>
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
<td>
<?php /* Der Text steht in einem eigenen Kasten, nicht direkt in
der Zelle: Abgeschnitten wird über zwei Zeilen, und das
kann nur ein Element, das kein Tabellenfeld ist. Die
Marke steht neben dem Kasten – im Kasten zählte sie als
eine der beiden Zeilen. */ ?>
<?php if ($zeile['gegensicht']): ?>
<div class="zellenreihe">
<div class="zellentext"><?= View::h($zeile['empfaenger']) ?></div>
<span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto">
Umbuchung
</span>
</div>
<?php else: ?>
<div class="zellentext">
<a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a>
</div>
<?php endif; ?>
</td>
<?php /* Ungekürzt: Was nicht auf zwei Zeilen passt, schneidet das
Stylesheet ab – der vollständige Text bleibt für den
Tooltip erhalten. */ ?>
<td class="nebentext buchungstext"><div class="zellentext"><?= View::h($zeile['buchungstext']) ?></div></td>
<?php
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
// diese Angabe stünde auf dem Gegenkonto eine Umbuchung ohne
// erkennbares Gegenüber.
$gegenkonto = empty($zeile['gegenkonto_id'])
? ''
: '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
$beschriftung = match (true) {
(int)$zeile['gesplittet'] === 1 => 'Splitbuchung',
$gegenkonto !== '' => $gegenkonto,
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
};
// Eine Splitbuchung nennt im Tooltip ihre Zeilen, alles andere
// seine eigene – womöglich abgeschnittene – Beschriftung.
$zuordnungstitel = match (true) {
isset($splits[(int)$zeile['id']]) => $splituebersicht($splits[(int)$zeile['id']]),
$beschriftung !== '' => $beschriftung,
default => 'Kategorie oder Gegenkonto zuordnen',
};
?>
<td class="zuordnungszelle">
<?php if ($zeile['gegensicht']): ?>
<?php /* Auch die Gegenseite führt zu ihrer Buchung – sie liegt
auf dem anderen Konto und wird nur dort geändert. */ ?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld gegenseite"
title="Umbuchung von <?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?> – dort zu ändern">
<?= View::h($beschriftung) ?>
</a>
<?php else: ?>
<?php /* Ohne JavaScript führt der Klick in die Buchungsmaske;
kontoblatt.js macht daraus die Auswahl an Ort und Stelle. */ ?>
<?php
// Wertpapierabrechnungen führen ISIN und WKN im
// Verwendungszweck mit. Wer die Zeile ins Depot bucht,
// bekommt das Papier deshalb vorgeschlagen, statt es
// in einer langen Liste zu suchen.
$papier = Wertpapier::ausText(
(string)$zeile['buchungstext'] . ' ' . (string)$zeile['empfaenger']
);
?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="zuordnungsfeld"
data-buchung="<?= (int)$zeile['id'] ?>"
data-wert="<?= View::h($zuordnungswert($zeile)) ?>"
data-betrag="<?= View::h(number_format((float)$zeile['betrag'], 2, '.', '')) ?>"
data-datum="<?= View::h(substr((string)$zeile['datum'], 0, 10)) ?>"
data-zweck="<?= View::h((string)$zeile['buchungstext']) ?>"
data-isin="<?= View::h($papier['isin']) ?>"
data-wkn="<?= View::h($papier['wkn']) ?>"
data-bezeichnung="<?= View::h(View::datum($zeile['datum']) . ' · ' . ($zeile['empfaenger'] ?: '(ohne Empfänger)')) ?>"
<?php if (isset($splits[(int)$zeile['id']])): ?>
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
<?php endif; ?>
title="<?= View::h($zuordnungstitel) ?>">
<?php if ($beschriftung === 'Splitbuchung'): ?>
<span class="marke">Splitbuchung</span>
<?php else: ?>
<?= View::h($beschriftung) ?>
<?php endif; ?>
</a>
<?php endif; ?>
</td>
<?php $klassenname = (string)($klassenNamen[(int)$zeile['klasse_id']] ?? ''); ?>
<td class="klassenzelle nebentext">
<?php if ($zeile['gegensicht']): ?>
<?= View::h($klassenname) ?>
<?php else: ?>
<?php /* Wie bei der Zuordnung: Ohne JavaScript führt der Klick
in die Buchungsmaske, mit ihm öffnet sich die Auswahl
an Ort und Stelle. */ ?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="klassenfeld"
data-buchung="<?= (int)$zeile['id'] ?>"
data-klasse="<?= (int)$zeile['klasse_id'] ?>"
title="<?= View::h($klassenname !== '' ? $klassenname : 'Klasse zuordnen') ?>">
<?= View::h($klassenname) ?>
</a>
<?php endif; ?>
</td>
<?php if ($bargeld): ?>
<td class="bargeldspalte"><?= $bargeldmarke($zeile) ?></td>
<?php endif; ?>
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>"><?= View::betrag($zeile['betrag']) ?></td>
<td class="rechts <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($zeile['saldo']) ?>">
<?= $zeile['saldo'] === null ? '–' : View::betrag($zeile['saldo']) ?>
</td>
<td class="nebentext"><?= (int)$zeile['abgeglichen'] === 1 ? '✓' : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="blaettern">
<span class="nebentext"><?= (int)$gesamt ?> Buchungen</span>
<?php if ($seiten > 1): ?>
<?php if ($seite > 1): ?>
<a href="<?= View::h($seitenPfad($seite - 1)) ?>" class="knopf klein">Neuer</a>
<?php endif; ?>
<span class="nebentext">Seite <?= (int)$seite ?> von <?= (int)$seiten ?></span>
<?php if ($seite < $seiten): ?>
<a href="<?= View::h($seitenPfad($seite + 1)) ?>" class="knopf klein">Älter</a>
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* Die Auswahlliste gibt es einmal; kontoblatt.js hängt sie in die
angeklickte Zelle. Eine Liste je Zeile wären bei hundert Zeilen
ebenso viele vollständige Kategoriebäume. */ ?>
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
<?php $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
</form>
<?php /* Dieselbe Bauart für die Klassenspalte: eine Liste, die in die
angeklickte Zelle wandert. Der erste Eintrag löscht die Klasse. */ ?>
<form method="post" action="" id="klassenauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<select name="klasse_id" aria-label="Klasse">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
<?php endforeach; ?>
</select>
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
</form>
<dialog id="splitdialog" class="splitdialog">
<form method="post" action="" id="splitformular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h2>Splitbuchung</h2>
<p class="feldhinweis">
<span id="splitbezeichnung"></span> über
<output id="splitbuchungsbetrag" class="betrag">0,00</output>
</p>
<table class="liste splittabelle">
<thead>
<tr>
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th>
<th class="rechts">Betrag</th>
</tr>
</thead>
<tbody id="splitdialogzeilen">
<?php for ($nr = 0; $nr < 4; $nr++): ?>
<tr>
<td>
<select name="split[<?= $nr ?>][zuordnung]">
<?php $gewaehlt = ''; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</td>
<td>
<select name="split[<?= $nr ?>][klasse_id]">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
<?php endforeach; ?>
</select>
</td>
<td><input type="text" name="split[<?= $nr ?>][notiz]" value=""></td>
<td>
<input type="text" class="splitbetrag rechts" inputmode="decimal"
name="split[<?= $nr ?>][betrag]" value="">
</td>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
</tr>
</tfoot>
</table>
<p class="feldhinweis">
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen. Bleiben alle
Zeilen leer, wird die Splitbuchung aufgehoben.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Splitbuchung speichern</button>
<button type="button" class="knopf" id="splitzeileMehr">Zeile hinzufügen</button>
<button type="button" class="knopf" id="splitabbrechen">Abbrechen</button>
<?php /* Rechts außen, abseits der übrigen Knöpfe: hebt die Aufteilung
samt aller Zuordnungen auf. Nur sichtbar, wenn es etwas
aufzuheben gibt – kontoblatt.js blendet ihn ein. */ ?>
<button type="submit" class="knopf gefahr" id="splitloeschen"
name="aktion" value="loeschen" formnovalidate hidden>
Splitbuchung löschen
</button>
</div>
</form>
</dialog>
<?php /* Ein Wertpapiergeschäft entsteht dort, wo seine Abrechnung ankommt:
am Girokonto. Die Auswahl eines Depots in der Zuordnungsliste öffnet
diesen Dialog; die Nebenkosten liegen dahinter im zweiten. */ ?>
<dialog id="wertpapierdialog" class="splitdialog wertpapierdialog">
<form method="post" action="" id="wertpapierformular" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="depot_id" id="wp_depot" value="">
<h2>Wertpapiergeschäft</h2>
<p class="feldhinweis">
<span id="wp_bezeichnung"></span> über
<output id="wp_buchungsbetrag" class="betrag">0,00</output> ·
Depot <strong id="wp_depotname"></strong>
</p>
<p class="feldhinweis verwendungszweck" id="wp_verwendungszweck"></p>
<?php $datumsvorgabe = date('Y-m-d'); ?>
<?php $vorschlag = ['isin' => '', 'wkn' => '']; ?>
<?php include __DIR__ . '/wertpapierfelder.php'; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Geschäft buchen</button>
<button type="button" class="knopf" id="wp_kostenknopf">
Kosten … (<output id="wp_kostenknopfsumme" class="betrag">0,00</output>)
</button>
<button type="button" class="knopf" id="wp_abbrechen">Abbrechen</button>
<span class="fuellung"></span>
<span class="saldoanzeige">
Einstandspreis <strong><output id="wp_einstand" class="betrag">0,00</output></strong>
</span>
</div>
</form>
</dialog>
<?php /* Die Kostenfelder gehören zum Formular oben, stehen aber in einem
eigenen Dialog – verbunden über das form-Attribut, wie schon die
Ankreuzfelder der Sammellöschung. */ ?>
<dialog id="kostendialog" class="splitdialog">
<h2>Kosten</h2>
<?php $kostenFormular = 'wertpapierformular'; ?>
<?php include __DIR__ . '/wertpapierkosten.php'; ?>
<div class="knopfleiste">
<button type="button" class="knopf primaer" id="wp_kostenfertig">Übernehmen</button>
</div>
</dialog>
<script src="/static/betrag.js" defer></script>
<script src="/static/wertpapierbuchung.js" defer></script>
<script src="/static/kontoblatt.js" defer></script>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>