Fix #41: Typeahead für Kategorien im Kontoblatt
Kategorie-Filter und Schnellzuordnung tragen jetzt data-kombobox: kombobox.js baut das <select> beim Laden in ein Eingabefeld mit gefilterter, aufklappender Liste um, ohne Verhalten oder Markup für vorhandenen Code (kontoblatt.js) zu ändern. Die Klassen-Auswahl bleibt bewusst ein gewöhnliches <select> – ihre Liste ist kurz.
This commit is contained in:
parent
1c3c0de604
commit
088cfbfe66
7 changed files with 525 additions and 2 deletions
14
Changes.md
14
Changes.md
|
|
@ -1,5 +1,19 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Kontoblatt: Typeahead für die Kategorie (19.08.2026)
|
||||||
|
|
||||||
|
Die Kategorie ließ sich im Kontoblatt nur über eine lange Auswahlliste finden – bei vielen
|
||||||
|
Kategorien ein langes Blättern (Feature #41). Ein neues `kombobox.js` baut ein markiertes
|
||||||
|
`<select data-kombobox>` beim Laden der Seite in ein Eingabefeld mit darunter aufklappender,
|
||||||
|
gefilterter Liste um; Gruppen (Optgroups) und die Reihenfolge der Optionen bleiben erhalten.
|
||||||
|
Das ursprüngliche `<select>` bleibt unsichtbar im Formular stehen, damit Wert, Name und das
|
||||||
|
Ändern-Ereignis für vorhandenen Code unverändert bleiben.
|
||||||
|
|
||||||
|
Betroffen sind der Kategorie-Filter oben im Kontoblatt und die Schnellzuordnung je Buchung
|
||||||
|
(`kontoblatt.js` öffnet dort die Kombobox statt `showPicker()`, sobald ein `<select>`
|
||||||
|
umgebaut wurde). Die Klassen-Auswahl bleibt bewusst ein gewöhnliches `<select>` – ihre Liste
|
||||||
|
ist kurz. Ohne JavaScript bleibt es überall beim normalen `<select>`.
|
||||||
|
|
||||||
## Kontoblatt: Schnellzuordnung ohne Neuladen (19.08.2026)
|
## Kontoblatt: Schnellzuordnung ohne Neuladen (19.08.2026)
|
||||||
|
|
||||||
Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die
|
Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,20 @@ gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, lö
|
||||||
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
|
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
|
||||||
Buchungsmaske.
|
Buchungsmaske.
|
||||||
|
|
||||||
|
### Typeahead (Feature #41)
|
||||||
|
|
||||||
|
Sowohl der Kategorie-Filter oben im Kontoblatt als auch die Auswahlliste der
|
||||||
|
Schnellzuordnung tragen `data-kombobox`. `static/kombobox.js` baut ein so markiertes
|
||||||
|
`<select>` beim Laden der Seite in ein Eingabefeld mit darunter aufklappender, gefilterter
|
||||||
|
Liste um – Optgroups bleiben als Überschriften erhalten. Das ursprüngliche `<select>`
|
||||||
|
bleibt unsichtbar im Formular stehen, Wert, Name und `change`-Ereignis ändern sich nicht;
|
||||||
|
`kontoblatt.js` liest und setzt weiterhin nur `select.value`.
|
||||||
|
|
||||||
|
Beim Öffnen der Schnellzuordnung ruft `kontoblatt.js` `select._kombobox.oeffnen()` statt
|
||||||
|
`showPicker()` auf – ein verstecktes `<select>` ließe sich weder fokussieren noch mit
|
||||||
|
`showPicker()` öffnen. Die Klassen-Auswahl bleibt bewusst ein gewöhnliches `<select>` ohne
|
||||||
|
`data-kombobox`: Ihre Liste ist kurz, ein Typeahead brächte dort nichts.
|
||||||
|
|
||||||
## Klasse im Kontoblatt
|
## Klasse im Kontoblatt
|
||||||
|
|
||||||
Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste
|
Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste
|
||||||
|
|
|
||||||
|
|
@ -1085,6 +1085,62 @@ tr.zugeklappt { display: none; }
|
||||||
.filterleiste select { max-width: 15rem; }
|
.filterleiste select { max-width: 15rem; }
|
||||||
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
|
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
|
||||||
|
|
||||||
|
/* Kombobox: <select> per Typeahead bedienbar, siehe kombobox.js (Fix #41). Das
|
||||||
|
Eingabefeld tritt optisch an die Stelle des <select>, darunter klappt die
|
||||||
|
gefilterte Liste auf. Größe und Rahmen übernimmt die Kombobox von dem
|
||||||
|
Feldtyp, in dem sie steht – Filterleiste und Schnellzuordnung sehen sonst
|
||||||
|
unterschiedlich groß aus. */
|
||||||
|
.kombobox { position: relative; display: inline-block; }
|
||||||
|
|
||||||
|
.kombobox-eingabe {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kombobox-liste {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: .2rem 0 0;
|
||||||
|
padding: .25rem 0;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
box-shadow: 0 6px 18px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kombobox-gruppe {
|
||||||
|
padding: .2rem .6rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kombobox-option { padding: .25rem .7rem; cursor: pointer; }
|
||||||
|
.kombobox-option.aktiv { background: var(--flaeche-gedaempft); }
|
||||||
|
.kombobox-leer { padding: .25rem .7rem; color: var(--text-gedaempft); }
|
||||||
|
|
||||||
|
.filterleiste .kombobox { max-width: 15rem; }
|
||||||
|
.filterleiste .kombobox-eingabe { padding: .3rem .45rem; font-size: .9rem; }
|
||||||
|
|
||||||
|
.zuordnungsauswahl .kombobox { width: 100%; max-width: 13rem; }
|
||||||
|
.zuordnungsauswahl .kombobox-eingabe {
|
||||||
|
height: 1.35rem;
|
||||||
|
padding: 0 .2rem;
|
||||||
|
border-color: var(--akzent);
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
table.kontoblatt { font-size: .89rem; }
|
table.kontoblatt { font-size: .89rem; }
|
||||||
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); }
|
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); }
|
||||||
|
|
||||||
|
|
|
||||||
306
public/static/kombobox.js
Normal file
306
public/static/kombobox.js
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
/* Kombobox: ein <select> per Typeahead bedienbar machen – ein Eingabefeld
|
||||||
|
* ersetzt es optisch, darunter erscheint die nach der Eingabe gefilterte
|
||||||
|
* Liste. Das ursprüngliche <select> bleibt im Formular stehen (unsichtbar):
|
||||||
|
* Wert, Name und das Ändern-Ereignis bleiben dieselben, jeder Code, der bisher
|
||||||
|
* mit dem <select> arbeitet (etwa kontoblatt.js), braucht dafür keine
|
||||||
|
* Sonderbehandlung.
|
||||||
|
*
|
||||||
|
* Ohne JavaScript bleibt es beim gewöhnlichen <select> – dieses Skript
|
||||||
|
* ersetzt nichts, wenn es nicht läuft.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let naechsteId = 0;
|
||||||
|
|
||||||
|
/* Die Einträge eines <select> als Gruppen – ohne Gruppe (kein <optgroup>
|
||||||
|
* darüber) landen die Optionen in einer Gruppe ohne Überschrift, in der
|
||||||
|
* Reihenfolge, in der sie im <select> stehen. */
|
||||||
|
function modellAus(select) {
|
||||||
|
const gruppen = [];
|
||||||
|
let offeneGruppe = null;
|
||||||
|
|
||||||
|
Array.from(select.children).forEach(function (kind) {
|
||||||
|
if (kind.tagName === 'OPTGROUP') {
|
||||||
|
gruppen.push({
|
||||||
|
label: kind.label,
|
||||||
|
optionen: Array.from(kind.children).map(eintragAus)
|
||||||
|
});
|
||||||
|
offeneGruppe = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind.tagName !== 'OPTION') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!offeneGruppe) {
|
||||||
|
offeneGruppe = { label: null, optionen: [] };
|
||||||
|
gruppen.push(offeneGruppe);
|
||||||
|
}
|
||||||
|
offeneGruppe.optionen.push(eintragAus(kind));
|
||||||
|
});
|
||||||
|
|
||||||
|
return gruppen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eintragAus(option) {
|
||||||
|
return { wert: option.value, text: option.textContent.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function passt(text, filter) {
|
||||||
|
return text.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Kombobox um `select` herum auf. Ein zweiter Aufruf für dasselbe
|
||||||
|
* <select> liefert dieselbe Instanz zurück, statt sie doppelt anzulegen.
|
||||||
|
*/
|
||||||
|
function verbessern(select) {
|
||||||
|
if (select._kombobox) {
|
||||||
|
return select._kombobox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gruppen = modellAus(select);
|
||||||
|
const alleEintraege = gruppen.reduce(function (liste, gruppe) {
|
||||||
|
return liste.concat(gruppe.optionen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const id = 'kombobox-' + (++naechsteId);
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'kombobox';
|
||||||
|
|
||||||
|
const eingabe = document.createElement('input');
|
||||||
|
eingabe.type = 'text';
|
||||||
|
eingabe.className = 'kombobox-eingabe';
|
||||||
|
eingabe.autocomplete = 'off';
|
||||||
|
eingabe.spellcheck = false;
|
||||||
|
eingabe.setAttribute('role', 'combobox');
|
||||||
|
eingabe.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
eingabe.setAttribute('aria-expanded', 'false');
|
||||||
|
eingabe.setAttribute('aria-autocomplete', 'list');
|
||||||
|
eingabe.setAttribute('aria-controls', id);
|
||||||
|
const beschriftung = select.getAttribute('aria-label');
|
||||||
|
if (beschriftung) {
|
||||||
|
eingabe.setAttribute('aria-label', beschriftung);
|
||||||
|
}
|
||||||
|
|
||||||
|
const liste = document.createElement('ul');
|
||||||
|
liste.className = 'kombobox-liste';
|
||||||
|
liste.id = id;
|
||||||
|
liste.setAttribute('role', 'listbox');
|
||||||
|
liste.hidden = true;
|
||||||
|
|
||||||
|
select.parentNode.insertBefore(wrapper, select);
|
||||||
|
wrapper.appendChild(eingabe);
|
||||||
|
wrapper.appendChild(liste);
|
||||||
|
wrapper.appendChild(select);
|
||||||
|
select.hidden = true;
|
||||||
|
|
||||||
|
let offen = false;
|
||||||
|
let aktivIndex = -1;
|
||||||
|
|
||||||
|
function optionenElemente() {
|
||||||
|
return Array.from(liste.querySelectorAll('.kombobox-option'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktivSetzen(index) {
|
||||||
|
const elemente = optionenElemente();
|
||||||
|
if (elemente.length === 0) {
|
||||||
|
aktivIndex = -1;
|
||||||
|
eingabe.removeAttribute('aria-activedescendant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
aktivIndex = Math.max(0, Math.min(index, elemente.length - 1));
|
||||||
|
elemente.forEach(function (el, i) {
|
||||||
|
const aktiv = i === aktivIndex;
|
||||||
|
el.classList.toggle('aktiv', aktiv);
|
||||||
|
el.setAttribute('aria-selected', aktiv ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
const aktivesElement = elemente[aktivIndex];
|
||||||
|
eingabe.setAttribute('aria-activedescendant', aktivesElement.id);
|
||||||
|
aktivesElement.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendern(filterText) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
let laufendeNummer = 0;
|
||||||
|
|
||||||
|
gruppen.forEach(function (gruppe) {
|
||||||
|
const treffer = gruppe.optionen.filter(function (eintrag) {
|
||||||
|
return passt(eintrag.text, filterText);
|
||||||
|
});
|
||||||
|
if (treffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gruppe.label) {
|
||||||
|
const kopf = document.createElement('li');
|
||||||
|
kopf.className = 'kombobox-gruppe';
|
||||||
|
kopf.setAttribute('role', 'presentation');
|
||||||
|
kopf.textContent = gruppe.label;
|
||||||
|
liste.appendChild(kopf);
|
||||||
|
}
|
||||||
|
|
||||||
|
treffer.forEach(function (eintrag) {
|
||||||
|
const punkt = document.createElement('li');
|
||||||
|
punkt.className = 'kombobox-option';
|
||||||
|
punkt.id = id + '-' + (++laufendeNummer);
|
||||||
|
punkt.setAttribute('role', 'option');
|
||||||
|
punkt.dataset.wert = eintrag.wert;
|
||||||
|
punkt.textContent = eintrag.text;
|
||||||
|
liste.appendChild(punkt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (liste.children.length === 0) {
|
||||||
|
const leer = document.createElement('li');
|
||||||
|
leer.className = 'kombobox-leer';
|
||||||
|
leer.setAttribute('role', 'presentation');
|
||||||
|
leer.textContent = 'Keine Treffer';
|
||||||
|
liste.appendChild(leer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elemente = optionenElemente();
|
||||||
|
if (elemente.length === 0) {
|
||||||
|
aktivIndex = -1;
|
||||||
|
eingabe.removeAttribute('aria-activedescendant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorauswahl = filterText.trim() === ''
|
||||||
|
? elemente.findIndex(function (el) { return el.dataset.wert === select.value; })
|
||||||
|
: 0;
|
||||||
|
aktivSetzen(vorauswahl === -1 ? 0 : vorauswahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function offenAnzeigen() {
|
||||||
|
liste.hidden = false;
|
||||||
|
eingabe.setAttribute('aria-expanded', 'true');
|
||||||
|
offen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliessen() {
|
||||||
|
liste.hidden = true;
|
||||||
|
eingabe.setAttribute('aria-expanded', 'false');
|
||||||
|
eingabe.removeAttribute('aria-activedescendant');
|
||||||
|
offen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeigt die Bezeichnung des gewählten Werts im Eingabefeld – ohne
|
||||||
|
* Auswahl bleibt es leer, wie es die erste Option („— keine —“ /
|
||||||
|
* „Alle Kategorien“) mit dem Wert '' vorsieht. */
|
||||||
|
function synchronisieren() {
|
||||||
|
const eintrag = alleEintraege.find(function (e) { return e.wert === select.value; });
|
||||||
|
eingabe.value = eintrag ? eintrag.text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function waehlen(punkt) {
|
||||||
|
const wertVorher = select.value;
|
||||||
|
select.value = punkt.dataset.wert;
|
||||||
|
eingabe.value = punkt.textContent.trim();
|
||||||
|
schliessen();
|
||||||
|
|
||||||
|
if (select.value !== wertVorher) {
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
eingabe.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Öffnet die Liste ungefiltert mit Fokus im Eingabefeld – der Aufruf
|
||||||
|
* von außen (kontoblatt.js), wenn die Kombobox an die Stelle eines
|
||||||
|
* angeklickten Feldes tritt. Der Text steht dabei markiert, damit
|
||||||
|
* Tippen ihn sofort ersetzt, wie es ein Klick auf ein <select> auch
|
||||||
|
* täte. */
|
||||||
|
function oeffnenUndFokussieren() {
|
||||||
|
synchronisieren();
|
||||||
|
rendern('');
|
||||||
|
offenAnzeigen();
|
||||||
|
eingabe.focus();
|
||||||
|
eingabe.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungefiltert, wie ein <select> beim Öffnen auch alle Optionen zeigt –
|
||||||
|
// gefiltert wird erst, sobald tatsächlich getippt wird (unten).
|
||||||
|
eingabe.addEventListener('focus', function () {
|
||||||
|
rendern('');
|
||||||
|
offenAnzeigen();
|
||||||
|
});
|
||||||
|
|
||||||
|
eingabe.addEventListener('input', function () {
|
||||||
|
rendern(eingabe.value);
|
||||||
|
offenAnzeigen();
|
||||||
|
});
|
||||||
|
|
||||||
|
eingabe.addEventListener('keydown', function (ereignis) {
|
||||||
|
if (ereignis.key === 'ArrowDown') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
if (!offen) {
|
||||||
|
// Wieder aufklappen (etwa nach Escape) zeigt erneut alles –
|
||||||
|
// gefiltert wird erst wieder durch neues Tippen.
|
||||||
|
rendern('');
|
||||||
|
offenAnzeigen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aktivSetzen(aktivIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.key === 'ArrowUp') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
if (offen) {
|
||||||
|
aktivSetzen(aktivIndex - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.key === 'Enter') {
|
||||||
|
if (!offen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const punkt = optionenElemente()[aktivIndex];
|
||||||
|
if (punkt) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
waehlen(punkt);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ereignis.key === 'Escape' && offen) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
schliessen();
|
||||||
|
synchronisieren();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eingabe.addEventListener('blur', function () {
|
||||||
|
schliessen();
|
||||||
|
synchronisieren();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* mousedown statt click, mit preventDefault: Sonst verlöre das
|
||||||
|
* Eingabefeld vor dem Klick den Fokus, und der blur-Handler schlösse
|
||||||
|
* die Liste, bevor die Auswahl ankommt. */
|
||||||
|
liste.addEventListener('mousedown', function (ereignis) {
|
||||||
|
const punkt = ereignis.target.closest('.kombobox-option');
|
||||||
|
if (!punkt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ereignis.preventDefault();
|
||||||
|
waehlen(punkt);
|
||||||
|
});
|
||||||
|
|
||||||
|
synchronisieren();
|
||||||
|
|
||||||
|
select._kombobox = {
|
||||||
|
synchronisieren: synchronisieren,
|
||||||
|
oeffnen: oeffnenUndFokussieren
|
||||||
|
};
|
||||||
|
return select._kombobox;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.FmKombobox = { verbessern: verbessern };
|
||||||
|
|
||||||
|
// Jedes so markierte <select> wird beim Laden der Seite umgebaut – ohne
|
||||||
|
// dass die einbindende Ansicht dafür eigenen Code braucht.
|
||||||
|
document.querySelectorAll('select[data-kombobox]').forEach(verbessern);
|
||||||
|
})();
|
||||||
|
|
@ -109,6 +109,15 @@
|
||||||
feld.parentNode.appendChild(formular);
|
feld.parentNode.appendChild(formular);
|
||||||
formular.hidden = false;
|
formular.hidden = false;
|
||||||
|
|
||||||
|
// Die Kategorieliste ist per kombobox.js eine Typeahead-Kombobox
|
||||||
|
// (Fix #41) – ihr <select> ist unsichtbar und ließe sich weder
|
||||||
|
// fokussieren noch mit showPicker öffnen. Die Klassenliste bleibt ein
|
||||||
|
// gewöhnliches <select>.
|
||||||
|
if (auswahlliste._kombobox) {
|
||||||
|
auswahlliste._kombobox.oeffnen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
auswahlliste.focus();
|
auswahlliste.focus();
|
||||||
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
|
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<label>von <input type="date" name="von" value="<?= View::h($filter['von'] ?? '') ?>"></label>
|
<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>
|
<label>bis <input type="date" name="bis" value="<?= View::h($filter['bis'] ?? '') ?>"></label>
|
||||||
|
|
||||||
<select name="kategorie_id">
|
<select name="kategorie_id" aria-label="Kategorie" data-kombobox>
|
||||||
<option value="">Alle Kategorien</option>
|
<option value="">Alle Kategorien</option>
|
||||||
<?php foreach ($kategorien as $k): ?>
|
<?php foreach ($kategorien as $k): ?>
|
||||||
<option value="<?= (int)$k['id'] ?>"
|
<option value="<?= (int)$k['id'] ?>"
|
||||||
|
|
@ -363,7 +363,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
ebenso viele vollständige Kategoriebäume. */ ?>
|
ebenso viele vollständige Kategoriebäume. */ ?>
|
||||||
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
|
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
|
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto" data-kombobox>
|
||||||
<?php $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
|
<?php $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
|
||||||
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -500,6 +500,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<script src="/static/betrag.js" defer></script>
|
<script src="/static/betrag.js" defer></script>
|
||||||
<script src="/static/wertpapierbuchung.js" defer></script>
|
<script src="/static/wertpapierbuchung.js" defer></script>
|
||||||
|
<script src="/static/kombobox.js" defer></script>
|
||||||
<script src="/static/kontoblatt.js" defer></script>
|
<script src="/static/kontoblatt.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
|
||||||
123
tests/35_KategorieTypeaheadTest.php
Normal file
123
tests/35_KategorieTypeaheadTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Kategorie;
|
||||||
|
use Finanzmanager\Models\Klasse;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix #41: Die Kategorie lässt sich im Kontoblatt per Typeahead eingeben,
|
||||||
|
* statt in einer langen Auswahlliste zu blättern – sowohl im Filter oben als
|
||||||
|
* auch in der Schnellzuordnung je Buchung. kombobox.js baut daraus im
|
||||||
|
* Browser ein Eingabefeld mit gefilterter Liste; geprüft wird hier nur, dass
|
||||||
|
* das gerenderte Kontoblatt die dafür nötigen Marken trägt und das Skript
|
||||||
|
* einbindet.
|
||||||
|
*/
|
||||||
|
class KategorieTypeaheadTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 35: Typeahead für Kategorien im Kontoblatt';
|
||||||
|
|
||||||
|
private int $konto = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->konto = Konto::anlegen([
|
||||||
|
'name' => 'Typeahead Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
Kategorie::anlegen(['name' => 'Typeahead Einkauf', 'art' => 'ausgabe']);
|
||||||
|
Klasse::anlegen('Typeahead Privat');
|
||||||
|
|
||||||
|
$html = $this->kontoblattHtml();
|
||||||
|
|
||||||
|
$this->filterSelectTraegtDieMarke($html);
|
||||||
|
$this->zuordnungSelectTraegtDieMarke($html);
|
||||||
|
$this->klasseBleibtEinGewoehnlichesSelect($html);
|
||||||
|
$this->skriptWirdVorKontoblattJsGeladen($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterSelectTraegtDieMarke(string $html): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
(bool)preg_match('#<select name="kategorie_id" aria-label="Kategorie" data-kombobox>#', $html),
|
||||||
|
'Der Kategorie-Filter ist als Kombobox markiert und trägt eine Beschriftung fürs Eingabefeld'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function zuordnungSelectTraegtDieMarke(string $html): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
(bool)preg_match(
|
||||||
|
'#<select name="zuordnung" aria-label="Kategorie oder Gegenkonto" data-kombobox>#',
|
||||||
|
$html
|
||||||
|
),
|
||||||
|
'Die Schnellzuordnung ist ebenfalls als Kombobox markiert'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Klassen-Auswahl ist keine Kategorie und bleibt bewusst ein
|
||||||
|
* gewöhnliches <select> – die Liste ist kurz, ein Typeahead brächte dort
|
||||||
|
* nichts.
|
||||||
|
*/
|
||||||
|
private function klasseBleibtEinGewoehnlichesSelect(string $html): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
(bool)preg_match('#<select name="klasse_id" aria-label="Klasse">#', $html),
|
||||||
|
'Die Klassen-Auswahl trägt keine Kombobox-Marke'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skriptWirdVorKontoblattJsGeladen(string $html): void
|
||||||
|
{
|
||||||
|
$kombobox = strpos($html, 'kombobox.js');
|
||||||
|
$kontoblatt = strpos($html, 'kontoblatt.js');
|
||||||
|
|
||||||
|
$this->assert($kombobox !== false, 'kombobox.js wird eingebunden');
|
||||||
|
$this->assert($kontoblatt !== false, 'kontoblatt.js wird eingebunden');
|
||||||
|
$this->assert(
|
||||||
|
$kombobox !== false && $kontoblatt !== false && $kombobox < $kontoblatt,
|
||||||
|
'kombobox.js lädt vor kontoblatt.js, das sich beim Laden darauf verlässt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine View rendern wie der Controller es täte – ohne Webserver.
|
||||||
|
*/
|
||||||
|
private function kontoblattHtml(): string
|
||||||
|
{
|
||||||
|
$zeilen = [[
|
||||||
|
'id' => 1, 'datum' => '2026-05-01', 'valuta' => null, 'betrag' => -10.0,
|
||||||
|
'empfaenger' => 'Zeile', 'buchungstext' => '', 'nummer' => '', 'abgeglichen' => 0,
|
||||||
|
'kategorie_id' => null, 'klasse_id' => null, 'gesplittet' => 0, 'quelle' => 'manuell',
|
||||||
|
'bargeld_status' => null, 'bargeld_hinweis' => null, 'gegenkonto_id' => null,
|
||||||
|
'konto_id' => $this->konto, 'gegensicht' => false, 'split_id' => null, 'saldo' => 100.0,
|
||||||
|
]];
|
||||||
|
|
||||||
|
$daten = [
|
||||||
|
'konto' => Konto::mitSalden(Konto::find($this->konto)),
|
||||||
|
'zeilen' => $zeilen,
|
||||||
|
'gesamt' => count($zeilen),
|
||||||
|
'seite' => 1,
|
||||||
|
'seiten' => 1,
|
||||||
|
'filter' => [],
|
||||||
|
'kategorien' => Kategorie::baumFlach(),
|
||||||
|
'klassen' => Klasse::alle(),
|
||||||
|
'konten' => Konto::alle(),
|
||||||
|
'splits' => [],
|
||||||
|
'wertpapiere' => [],
|
||||||
|
'bargeld' => false,
|
||||||
|
'titel' => 'Test',
|
||||||
|
'flash' => [],
|
||||||
|
'benutzer' => null,
|
||||||
|
'csrfToken' => 'x',
|
||||||
|
'aktuellerPfad' => '/konten/' . $this->konto,
|
||||||
|
];
|
||||||
|
|
||||||
|
extract($daten, EXTR_SKIP);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/src/Views/buchungen/kontoblatt.php';
|
||||||
|
return (string)ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue