Fix #126: Taschenrechner an den Betragsfeldern

Jedes Zahlenfeld (inputmode="decimal") zeigt rechts ein Rechnersymbol.
Klick darauf oder Alt+R öffnet einen Taschenrechner mit Grundrechenarten
und Klammern, komplett per Tastatur bedienbar: Ziffern, + - * / ( ),
Backspace, Entf, Pfeiltasten, C löscht alles, = setzt das Ergebnis ein,
Enter überträgt ins Feld, Escape schließt und fokussiert das Feld. Das
Ergebnis steht nach jedem Tastendruck unter der Rechnung.

Das Symbol ist ein Hintergrundbild (kein Wrapper-Element, kein Eingriff in
die Layouts); gerechnet wird mit eigenem Parser statt eval. In modalen
Dialogen wandert der Rechner mit hinein. Stück/Kurs übernehmen 6,
Zinssätze 3 Nachkommastellen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-13 02:22:18 +02:00
parent 57c6253997
commit d1db40fa55
14 changed files with 981 additions and 10 deletions

View file

@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added
- Taschenrechner an allen Betragsfeldern: Symbol rechts im Feld bzw. Alt+R öffnet einen
Rechner mit den Grundrechenarten und Klammern, komplett per Tastatur bedienbar (C
löscht, Enter überträgt, Escape schließt); das Ergebnis erscheint nach jedem
Tastendruck (#126)
- Buchungsbericht: Spalte „✓“ (abgeglichen) vor der Spalte „Bar“ des Bargeld-Tools und
Filter „Abgeglichen“ (alle / abgeglichen / nicht abgeglichen); „Buchungen nicht
abgeglichen“ unter den offenen Vorgängen im Cockpit öffnet den Buchungsbericht mit

View file

@ -1,5 +1,14 @@
# Änderungen
## Taschenrechner an den Betragsfeldern (13.10.2026)
- Anlass (#126): In Betragsfeldern sollte sich rechnen lassen, komplett mit der Tastatur bedienbar.
- Jedes Zahlenfeld (`inputmode="decimal"`) zeigt rechts ein Rechnersymbol; Klick darauf oder Alt+R öffnet den Taschenrechner (`public/static/rechner.js`, im Seitenrahmen eingebunden, ebenso `betrag.js`). Anzeigezeile mit Rechnung und darunter dem Ergebnis nach jedem Tastendruck; Ziffern, `+ - * / ( )`, Backspace, Entf, Pfeiltasten, `C` (alles löschen), `=` (Ergebnis weiterverwenden), Enter (übertragen), Escape (schließen, Fokus ins Feld). Tastenfeld zusätzlich zum Anklicken, Farben aus dem Thema.
- Das Symbol ist ein Hintergrundbild – kein Element um das Feld, kein Eingriff in die Layouts. Schmale Inline-Felder (`.schmal`) und die Budgetplaner-Felder sind dafür etwas breiter.
- Stück und Kurs übernehmen 6, Zinssätze 3 Nachkommastellen (`data-nachkommastellen`), alle anderen Felder 2.
- Regelmäßige Buchungen: Das Inline-Feld klappt nicht mehr zu, solange der Rechner offen ist (`reihenliste.js`).
- Tests: `tests/66_TaschenrechnerTest.php`, `tests/e2e/specs/rechner.spec.js`.
## Buchungsbericht: Spalte und Filter „abgeglichen“ (12.10.2026)
- Anlass (#137): Der Buchungsbericht zeigte nicht, ob eine Buchung abgeglichen ist, und ließ sich nicht danach filtern.

View file

@ -52,6 +52,8 @@ Haushalt wird beides parallel genutzt.
Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter
*Verwaltung → Bargeld-Tool*. Sammelbuchungen und Differenzausgleiche lassen sich in der
Buchungsmaske bewusst ohne Bargeld-Abgleich buchen.
* **Taschenrechner** – in jedem Betragsfeld über das Symbol rechts im Feld (oder Alt+R),
komplett mit der Tastatur bedienbar; Enter überträgt das Ergebnis ins Feld.
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.

View file

@ -258,6 +258,37 @@ Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswah
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).
### Taschenrechner (Feature #126)
Jedes Zahlenfeld (`<input type="text" inputmode="decimal">`) trägt rechts ein
Rechnersymbol. Ein Klick darauf – oder **Alt+R** im Feld – öffnet unter dem Feld (fehlt
dort der Platz, darüber) einen Taschenrechner mit den Grundrechenarten. In der
Anzeigezeile steht oben die Rechnung, darunter nach jedem Tastendruck ihr Ergebnis; der
bisherige Feldwert ist die erste Zahl der Rechnung.
| Taste | Wirkung |
|---|---|
| `0`–`9`, `,` bzw. `.` | Ziffer bzw. Dezimalkomma |
| `+` `-` `*` `/` `(` `)` | Grundrechenart, Klammern (Punkt vor Strich) |
| Backspace / Entf | Zeichen links / rechts der Schreibmarke löschen |
| Pfeil links / rechts | Schreibmarke bewegen |
| `C` | gesamte Eingabe löschen |
| `=` | Rechnung durch ihr Ergebnis ersetzen |
| Enter | Ergebnis ins Feld übertragen, Rechner schließen |
| Escape | schließen ohne Übernahme, Fokus zurück ins Feld |
Übertragen wird mit zwei Nachkommastellen; Felder mit `data-nachkommastellen` (Stück und
Kurs: 6, Zinssatz: 3) übernehmen genauer. Das Feld bekommt `input`- und
`change`-Ereignisse, als wäre der Wert getippt – Restbeträge der Splitbuchung, die
Depotberechnung und die Speichern-Knöpfe der Listen ziehen mit. `data-ohne-rechner`
nimmt ein Feld aus.
Umgesetzt in `public/static/rechner.js` (im Seitenrahmen eingebunden), ohne Element um
das Feld: Das Symbol ist ein Hintergrundbild aus `app.css`, den Klick darauf erkennt das
Skript an der Position. Gerechnet wird mit einem eigenen kleinen Parser statt `eval` (die
CSP verböte es ohnehin). Steht das Feld in einem modalen `<dialog>`, wandert der Rechner
mit hinein; Escape schließt dann nur den Rechner, nicht den Dialog.
## Bestätigungen und die Content-Security-Policy
Die CSP erlaubt keine Inline-Skripte (`script-src 'self'`). Rückfragen vor dem Löschen

View file

@ -963,9 +963,10 @@ table.budgetplaner .laufend { background: color-mix(in srgb, var(--akzent) 8%, t
table.budgetplaner tr.summenzeile-kategorie td { font-weight: 600; }
/* Breit genug für „123.456,78“ – eine Stelle mehr, als der Haushalt braucht. */
/* Breit genug für „123.456,78“ – eine Stelle mehr, als der Haushalt braucht –
neben dem Rechnersymbol. */
table.budgetplaner input.betragsfeld {
width: 7rem;
width: 8.5rem;
text-align: right;
font-family: var(--schrift-zahl);
font-variant-numeric: tabular-nums;
@ -2538,3 +2539,111 @@ table.liste.reihen [hidden] { display: none; }
background: color-mix(in srgb, var(--akzent) 8%, transparent);
color: var(--text);
}
/* Taschenrechner an den Zahlenfeldern (rechner.js, Fix #126). Das Symbol ist
ein Hintergrundbild im rechten Innenabstand – ein Element um das Feld griffe
in jedes Layout ein, in dem ein Betragsfeld steht. Die hohe Spezifität
überstimmt die Abstände aus .formular und table.liste. */
input[type="text"][inputmode="decimal"]:not([data-ohne-rechner]):not([readonly]):not(:disabled) {
padding-right: 1.7rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%237b8794' stroke-width='1.3'%3E%3Crect x='2.5' y='1.5' width='11' height='13' rx='1.5'/%3E%3Crect x='4.5' y='3.5' width='7' height='3' rx='.5'/%3E%3Cpath d='M5 9h.01M8 9h.01M11 9h.01M5 12h.01M8 12h.01M11 12h.01' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .4rem center;
background-size: 1rem 1rem;
}
input[type="text"][inputmode="decimal"].rechner-zeiger { cursor: pointer; }
/* Schmale Inline-Felder behielten neben dem Symbol kaum Platz für „1.234,56“. */
input.schmal[inputmode="decimal"]:not([data-ohne-rechner]) { width: 6.5rem; }
.rechner {
position: fixed;
inset: auto;
margin: 0;
z-index: 1000;
width: 15rem;
padding: .6rem;
border: var(--bereichsrand);
border-radius: var(--radius);
background: var(--flaeche);
color: var(--text);
box-shadow: var(--bereichsschatten);
}
.rechner[hidden] { display: none; }
/* Die „Display“-Zeile: oben die Rechnung, darunter ihr Ergebnis. */
.rechner-anzeige {
margin-bottom: .5rem;
padding: .35rem .5rem;
border: 1px solid var(--rand-kraeftig);
border-radius: 4px;
background: var(--flaeche-gedaempft);
font-family: var(--schrift-zahl);
font-variant-numeric: tabular-nums;
text-align: right;
}
.rechner-anzeige:focus-within {
outline: 2px solid var(--akzent);
outline-offset: 1px;
}
.rechner-eingabe {
width: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text-gedaempft);
font: inherit;
font-size: .95rem;
text-align: right;
}
.rechner-ergebnis {
display: block;
min-height: 1.9rem;
font-size: 1.35rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.rechner-ergebnis.veraltet { color: var(--text-gedaempft); font-weight: 400; }
.rechner-ergebnis.fehler { color: var(--negativ); font-size: .9rem; font-weight: 500; }
.rechner-tasten {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: .3rem;
}
.rechner-tasten button {
padding: .45rem 0;
border: 1px solid var(--rand-kraeftig);
border-radius: 4px;
background: var(--flaeche);
color: var(--text);
font: inherit;
font-family: var(--schrift-zahl);
cursor: pointer;
}
.rechner-tasten button:hover { background: var(--flaeche-gedaempft); }
.rechner-tasten button.operator {
background: var(--akzent-schwach);
color: var(--akzent-schrift);
}
.rechner-tasten button.loeschen { color: var(--negativ); }
.rechner-tasten button.uebernehmen {
grid-column: 1 / -1;
border-color: var(--akzent);
background: var(--akzent);
color: var(--akzent-text);
font-family: var(--schrift);
font-weight: 600;
}

552
public/static/rechner.js Normal file
View file

@ -0,0 +1,552 @@
/* Taschenrechner an den Betragsfeldern (Fix #126).
*
* Jedes Zahlenfeld (`inputmode="decimal"`) trägt rechts ein Rechnersymbol. Das
* Symbol ist nur ein Hintergrundbild aus app.css: Ein eigenes Element um das
* Feld griffe in Dutzende Layouts ein – Grids, Tabellenzellen, schmale
* Inline-Felder. Den Klick darauf erkennt dieses Skript an der Position.
* Alt+R öffnet den Rechner auch von der Tastatur aus.
*
* Der Rechner ist mit der Tastatur bedienbar: Ziffern und + - * / ( ) tippen,
* Backspace/Entf/Pfeiltasten wie gewohnt, C löscht alles, = setzt das Ergebnis
* als neue Rechnung ein, Enter überträgt es ins Feld, Escape schließt. Das
* Ergebnis steht nach jedem Tastendruck unter der Rechnung.
*
* Gerechnet wird mit einem eigenen kleinen Parser – `eval` verböte die
* Content-Security-Policy, und auf Eingaben gehört es ohnehin nicht.
*
* Felder mit `data-ohne-rechner` bleiben außen vor. `data-nachkommastellen`
* legt fest, wie genau das Ergebnis übertragen wird (Vorgabe: 2).
*/
(function () {
'use strict';
const AUSWAHL = 'input[type="text"][inputmode="decimal"]:not([data-ohne-rechner])';
/* Was die Tastatur liefert, wie es in der Anzeige erscheint. */
const ZEICHEN = {
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
'+': '+', '-': '−', '*': '×', '/': '÷', '(': '(', ')': ')',
',': ',', '.': ',',
'−': '−', '×': '×', '÷': '÷', ':': '÷', 'x': '×'
};
/* Das Tastenfeld, zeilenweise. */
const TASTEN = [
['C', '(', ')', '⌫'],
['7', '8', '9', '÷'],
['4', '5', '6', '×'],
['1', '2', '3', '−'],
['0', ',', '=', '+']
];
const OPERATOREN = ['÷', '×', '−', '+', '(', ')'];
/* ── Rechnen ─────────────────────────────────────────────────────────── */
function RechenFehler(art) {
this.art = art;
}
/* Rechnung → Zahl. Wirft RechenFehler mit der Art „unvollstaendig“ (die
* Eingabe hört mitten in der Rechnung auf), „ungueltig“ oder „null“
* (Division durch null). */
function auswerten(text) {
const s = text
.replace(/\s+/g, '')
.replace(/×/g, '*')
.replace(/÷/g, '/')
.replace(/−/g, '-')
.replace(/,/g, '.');
let pos = 0;
function ende() {
return pos >= s.length;
}
function zahl() {
const treffer = /^(\d+\.?\d*|\.\d+|\.)/.exec(s.slice(pos));
if (!treffer) {
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
}
pos += treffer[0].length;
if (treffer[0] === '.') {
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
}
return parseFloat(treffer[0]);
}
function faktor() {
if (ende()) {
throw new RechenFehler('unvollstaendig');
}
const z = s[pos];
if (z === '+' || z === '-') {
pos++;
const wert = faktor();
return z === '-' ? -wert : wert;
}
if (z === '(') {
pos++;
const wert = ausdruck();
if (ende()) {
throw new RechenFehler('unvollstaendig');
}
if (s[pos] !== ')') {
throw new RechenFehler('ungueltig');
}
pos++;
return wert;
}
return zahl();
}
function term() {
let wert = faktor();
while (!ende() && (s[pos] === '*' || s[pos] === '/')) {
const op = s[pos++];
const rechts = faktor();
if (op === '/' && rechts === 0) {
throw new RechenFehler('null');
}
wert = op === '*' ? wert * rechts : wert / rechts;
}
return wert;
}
function ausdruck() {
let wert = term();
while (!ende() && (s[pos] === '+' || s[pos] === '-')) {
const op = s[pos++];
const rechts = term();
wert = op === '+' ? wert + rechts : wert - rechts;
}
return wert;
}
const wert = ausdruck();
if (!ende()) {
throw new RechenFehler('ungueltig');
}
// Gleitkommareste wie 0,1 + 0,2 = 0,30000000000000004 abschneiden.
return Math.round(wert * 1e10) / 1e10;
}
/* Für die Anzeige im Rechner: so viele Stellen wie nötig, mit Tausenderpunkt. */
function anzeigeText(wert) {
return wert.toLocaleString('de-DE', { maximumFractionDigits: 10 });
}
/* Für das Feld: ohne Tausenderpunkt, wie die Felder ihre Werte vom Server
* bekommen (number_format(…, 2, ',', '')). */
function feldText(wert, stellen) {
return wert.toLocaleString('de-DE', {
minimumFractionDigits: Math.min(2, stellen),
maximumFractionDigits: stellen,
useGrouping: false
});
}
/* Der bisherige Feldinhalt als Startwert der Rechnung. */
function startwert(feld) {
const text = feld.value.trim();
if (text === '') {
return '';
}
const wert = window.FmBetrag
? window.FmBetrag.zahl(text)
: parseFloat(text.replace(/\./g, '').replace(',', '.')) || 0;
if (wert === 0) {
return '';
}
return wert
.toLocaleString('de-DE', { maximumFractionDigits: 10, useGrouping: false })
.replace('-', '−');
}
/* ── Oberfläche ──────────────────────────────────────────────────────── */
const mitPopover = typeof HTMLElement.prototype.showPopover === 'function';
let rechner = null;
let eingabe = null;
let ergebnis = null;
let offenesFeld = null;
let letzterWert = null;
function bauen() {
rechner = document.createElement('div');
rechner.className = 'rechner';
rechner.setAttribute('role', 'dialog');
rechner.setAttribute('aria-label', 'Taschenrechner');
if (mitPopover) {
rechner.popover = 'manual';
} else {
rechner.hidden = true;
}
const anzeige = document.createElement('div');
anzeige.className = 'rechner-anzeige';
eingabe = document.createElement('input');
eingabe.type = 'text';
eingabe.className = 'rechner-eingabe';
eingabe.autocomplete = 'off';
eingabe.spellcheck = false;
eingabe.setAttribute('inputmode', 'none');
eingabe.setAttribute('aria-label', 'Rechnung');
ergebnis = document.createElement('output');
ergebnis.className = 'rechner-ergebnis';
ergebnis.setAttribute('aria-live', 'polite');
anzeige.append(eingabe, ergebnis);
const tasten = document.createElement('div');
tasten.className = 'rechner-tasten';
TASTEN.flat().forEach(function (taste) {
tasten.appendChild(knopf(taste, taste));
});
const uebernehmen = knopf('Übernehmen', 'Enter');
uebernehmen.classList.add('uebernehmen');
uebernehmen.title = 'Ergebnis ins Feld übernehmen (Enter)';
tasten.appendChild(uebernehmen);
rechner.append(anzeige, tasten);
eingabe.addEventListener('keydown', tastendruck);
eingabe.addEventListener('input', bereinigen);
rechner.addEventListener('focusout', function (ereignis) {
// Mit Tab hinaus: zu. Ohne Ziel (Fensterwechsel) bleibt er offen.
if (ereignis.relatedTarget && !rechner.contains(ereignis.relatedTarget)) {
schliessen(false);
}
});
}
function knopf(beschriftung, taste) {
const k = document.createElement('button');
k.type = 'button';
k.tabIndex = -1;
k.textContent = beschriftung;
k.dataset.taste = taste;
if (OPERATOREN.indexOf(taste) !== -1) {
k.classList.add('operator');
}
if (taste === 'C' || taste === '⌫') {
k.classList.add('loeschen');
}
if (taste === '⌫') {
k.setAttribute('aria-label', 'Zeichen löschen');
}
// Der Fokus bleibt in der Rechnung, sonst stünde die Schreibmarke
// nach jedem Klick woanders.
k.addEventListener('mousedown', function (ereignis) {
ereignis.preventDefault();
});
k.addEventListener('click', function () {
taste === 'Enter' ? uebernehmen() : drueckeTaste(taste);
eingabe.focus();
});
return k;
}
function einfuegen(text) {
eingabe.setRangeText(text, eingabe.selectionStart, eingabe.selectionEnd, 'end');
rechnen();
}
function drueckeTaste(taste) {
if (taste === 'C') {
eingabe.value = '';
rechnen();
} else if (taste === '⌫') {
const start = eingabe.selectionStart;
const ende = eingabe.selectionEnd;
if (start !== ende) {
eingabe.setRangeText('', start, ende, 'end');
} else if (start > 0) {
eingabe.setRangeText('', start - 1, start, 'end');
}
rechnen();
} else if (taste === '=') {
ergebnisEinsetzen();
} else {
einfuegen(taste);
}
}
/* „=“: Die Rechnung weicht ihrem Ergebnis, weitergerechnet wird damit. */
function ergebnisEinsetzen() {
if (letzterWert === null || eingabe.value.trim() === '') {
return;
}
eingabe.value = feldText(letzterWert, 10).replace('-', '−');
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
rechnen();
}
function tastendruck(ereignis) {
const taste = ereignis.key;
// Kopieren, Einfügen, Alles markieren bleiben, wie sie sind.
if (ereignis.ctrlKey || ereignis.metaKey) {
return;
}
if (taste === 'Enter') {
ereignis.preventDefault();
ereignis.stopPropagation();
uebernehmen();
return;
}
if (taste === 'Escape') {
// Nicht weiterreichen: Sonst schlösse Escape auch einen Dialog,
// in dem das Feld steht, oder das Kontoblatt verwürfe eine Auswahl.
ereignis.preventDefault();
ereignis.stopPropagation();
schliessen(true);
return;
}
if (taste === 'c' || taste === 'C') {
ereignis.preventDefault();
drueckeTaste('C');
return;
}
if (taste === '=') {
ereignis.preventDefault();
ergebnisEinsetzen();
return;
}
if (Object.prototype.hasOwnProperty.call(ZEICHEN, taste)) {
ereignis.preventDefault();
einfuegen(ZEICHEN[taste]);
return;
}
// Backspace, Entf, Pfeile, Pos1/Ende, Tab: wie in jedem Textfeld.
// Jedes andere druckbare Zeichen gehört nicht in eine Rechnung.
if (taste.length === 1 && !ereignis.altKey) {
ereignis.preventDefault();
}
}
/* Nach Einfügen aus der Zwischenablage: nur übernehmen, was rechnen kann. */
function bereinigen() {
const vorher = eingabe.value;
let marke = eingabe.selectionStart;
let sauber = '';
for (let i = 0; i < vorher.length; i++) {
const z = vorher[i];
if (Object.prototype.hasOwnProperty.call(ZEICHEN, z)) {
sauber += ZEICHEN[z];
} else if (i < marke) {
marke--;
}
}
if (sauber !== vorher) {
eingabe.value = sauber;
eingabe.setSelectionRange(marke, marke);
}
rechnen();
}
function rechnen() {
const text = eingabe.value;
ergebnis.classList.remove('veraltet', 'fehler');
if (text.trim() === '') {
letzterWert = null;
ergebnis.textContent = '0';
ergebnis.classList.add('veraltet');
return;
}
try {
letzterWert = auswerten(text);
ergebnis.textContent = anzeigeText(letzterWert);
} catch (fehler) {
if (!(fehler instanceof RechenFehler)) {
throw fehler;
}
letzterWert = null;
if (fehler.art === 'unvollstaendig') {
// Mitten in der Rechnung: das zuletzt gültige Ergebnis
// gedämpft stehen lassen, statt bei jedem Operator zu flackern.
ergebnis.classList.add('veraltet');
} else {
ergebnis.classList.add('fehler');
ergebnis.textContent = fehler.art === 'null'
? 'Division durch 0'
: 'Ungültige Rechnung';
}
}
}
function uebernehmen() {
const feld = offenesFeld;
if (!feld) {
return;
}
if (eingabe.value.trim() !== '') {
if (letzterWert === null) {
// Unvollständig oder fehlerhaft: nichts übertragen.
if (!ergebnis.classList.contains('fehler')) {
ergebnis.textContent = 'Rechnung unvollständig';
}
ergebnis.classList.remove('veraltet');
ergebnis.classList.add('fehler');
return;
}
const stellen = parseInt(feld.dataset.nachkommastellen || '2', 10);
feld.value = feldText(letzterWert, isNaN(stellen) ? 2 : stellen);
// Restbeträge, Depotberechnung, „Speichern“-Knöpfe hängen an diesen
// Ereignissen – sie sollen den neuen Wert sehen wie einen getippten.
feld.dispatchEvent(new Event('input', { bubbles: true }));
feld.dispatchEvent(new Event('change', { bubbles: true }));
}
schliessen(true);
}
function positionieren() {
if (!offenesFeld) {
return;
}
const r = offenesFeld.getBoundingClientRect();
const breite = rechner.offsetWidth;
const hoehe = rechner.offsetHeight;
const rand = 8;
let links = Math.min(r.right - breite, window.innerWidth - breite - rand);
links = Math.max(rand, links);
let oben = r.bottom + 4;
if (oben + hoehe > window.innerHeight - rand && r.top - hoehe - 4 >= rand) {
oben = r.top - hoehe - 4;
}
rechner.style.left = links + 'px';
rechner.style.top = oben + 'px';
}
function istOffen() {
return offenesFeld !== null;
}
function oeffnen(feld) {
if (!rechner) {
bauen();
}
if (istOffen()) {
schliessen(false);
}
// In einem modalen Dialog wäre alles außerhalb inaktiv – der Rechner
// muss deshalb mit in den Dialog.
const heimat = feld.closest('dialog') || document.body;
if (rechner.parentNode !== heimat) {
heimat.appendChild(rechner);
}
offenesFeld = feld;
eingabe.value = startwert(feld);
ergebnis.textContent = '';
rechnen();
if (mitPopover) {
rechner.showPopover();
} else {
rechner.hidden = false;
}
positionieren();
eingabe.focus();
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
}
/* `fokus`: das Feld wieder in den Fokus nehmen (Enter, Escape). */
function schliessen(fokus) {
const feld = offenesFeld;
if (!feld) {
return;
}
offenesFeld = null;
if (mitPopover) {
if (rechner.matches(':popover-open')) {
rechner.hidePopover();
}
} else {
rechner.hidden = true;
}
if (fokus) {
feld.focus();
}
feld.dispatchEvent(new CustomEvent('rechner:zu', { detail: { fokus: fokus } }));
}
/* ── Anbindung an die Felder ─────────────────────────────────────────── */
function bedienbar(feld) {
return feld instanceof HTMLInputElement
&& feld.matches(AUSWAHL)
&& !feld.readOnly
&& !feld.disabled
&& !feld.hidden;
}
/* Liegt der Zeiger über dem Symbol? Die Fläche ist der rechte Innenabstand,
* den app.css für das Symbol freihält. */
function aufSymbol(feld, ereignis) {
const stil = getComputedStyle(feld);
const zone = parseFloat(stil.paddingRight) + parseFloat(stil.borderRightWidth);
const r = feld.getBoundingClientRect();
return ereignis.clientX >= r.right - zone && ereignis.clientX <= r.right
&& ereignis.clientY >= r.top && ereignis.clientY <= r.bottom;
}
document.addEventListener('mousemove', function (ereignis) {
const feld = ereignis.target;
if (feld instanceof HTMLInputElement && bedienbar(feld)) {
const drauf = aufSymbol(feld, ereignis);
feld.classList.toggle('rechner-zeiger', drauf);
// Der Hinweis nur über dem Symbol – und nie anstelle eines eigenen.
if (drauf && !feld.title) {
feld.title = 'Taschenrechner (Alt+R)';
feld.dataset.rechnerTitel = '';
} else if (!drauf && feld.dataset.rechnerTitel !== undefined) {
feld.removeAttribute('title');
delete feld.dataset.rechnerTitel;
}
}
});
document.addEventListener('mousedown', function (ereignis) {
const ziel = ereignis.target;
if (ziel instanceof HTMLInputElement && bedienbar(ziel) && aufSymbol(ziel, ereignis)) {
// Kein Fokus, keine Schreibmarke im Feld – der Klick gilt dem Symbol.
ereignis.preventDefault();
if (offenesFeld === ziel) {
schliessen(true);
} else {
oeffnen(ziel);
}
return;
}
// Klick daneben: zu, ohne dem Klick den Fokus streitig zu machen.
if (istOffen() && ziel instanceof Node && !rechner.contains(ziel)) {
schliessen(false);
}
});
document.addEventListener('keydown', function (ereignis) {
if (ereignis.altKey && !ereignis.ctrlKey && !ereignis.metaKey && ereignis.code === 'KeyR'
&& ereignis.target instanceof HTMLInputElement && bedienbar(ereignis.target)) {
ereignis.preventDefault();
oeffnen(ereignis.target);
}
});
window.addEventListener('resize', positionieren);
// Mitlaufen, wenn die Seite oder ein Dialog darunter scrollt.
document.addEventListener('scroll', positionieren, true);
window.FmRechner = { auswerten: auswerten, oeffnen: oeffnen };
})();

View file

@ -93,7 +93,19 @@
bearbeiten();
}
});
steuer.addEventListener('blur', function () { zeige(feld); });
// Der Taschenrechner (rechner.js) leiht sich den Fokus nur: Das Feld
// bleibt offen, bis er wieder zu ist.
steuer.addEventListener('blur', function (e) {
if (e.relatedTarget && e.relatedTarget.closest('.rechner')) {
return;
}
zeige(feld);
});
steuer.addEventListener('rechner:zu', function (e) {
if (!e.detail.fokus) {
zeige(feld);
}
});
steuer.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
steuer.blur();

View file

@ -92,11 +92,11 @@ use Finanzmanager\View;
<div class="feldpaar">
<div>
<label for="wp_stueck">Stück</label>
<input type="text" id="wp_stueck" name="stueck" inputmode="decimal">
<input type="text" id="wp_stueck" name="stueck" inputmode="decimal" data-nachkommastellen="6">
</div>
<div>
<label for="wp_kurs">Kurs <span class="optional">(optional)</span></label>
<input type="text" id="wp_kurs" name="kurs" inputmode="decimal">
<input type="text" id="wp_kurs" name="kurs" inputmode="decimal" data-nachkommastellen="6">
</div>
</div>
<p class="feldhinweis">

View file

@ -51,6 +51,10 @@ $istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad
<!-- Nur hier eingebunden: Ein zweites Mal im Fuß ließe jeden Handler doppelt
laufen – Rückfragen kämen zweimal, Menüs entstünden zweimal. -->
<script src="/static/app.js" defer></script>
<!-- Der Taschenrechner hängt an jedem Zahlenfeld jeder Seite; betrag.js
liefert ihm das Lesen deutscher Beträge. -->
<script src="/static/betrag.js" defer></script>
<script src="/static/rechner.js" defer></script>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%231d6fb8'/><text x='8' y='12.5' font-size='12' font-weight='bold' text-anchor='middle' fill='white' font-family='sans-serif'>€</text></svg>">
</head>
<body>

View file

@ -146,7 +146,7 @@ include __DIR__ . '/../layout/kopf.php';
</div>
<div>
<label for="b_zins">Zinssatz (%)</label>
<input type="text" id="b_zins" name="zinssatz" inputmode="decimal"
<input type="text" id="b_zins" name="zinssatz" inputmode="decimal" data-nachkommastellen="3"
value="<?= View::h(number_format((float)$kredit['zinssatz'], 3, ',', '')) ?>">
</div>
<div>
@ -224,7 +224,7 @@ include __DIR__ . '/../layout/kopf.php';
</div>
<div>
<label for="zinssatz">Zinssatz (%)</label>
<input type="text" id="zinssatz" name="zinssatz" inputmode="decimal" required>
<input type="text" id="zinssatz" name="zinssatz" inputmode="decimal" data-nachkommastellen="3" required>
</div>
<div>
<label for="rate">Rate</label>

View file

@ -92,11 +92,11 @@ include __DIR__ . '/../layout/kopf.php';
<div class="feldpaar">
<div>
<label for="stueck">Stück</label>
<input type="text" id="stueck" name="stueck" inputmode="decimal">
<input type="text" id="stueck" name="stueck" inputmode="decimal" data-nachkommastellen="6">
</div>
<div>
<label for="kurs">Kurs</label>
<input type="text" id="kurs" name="kurs" inputmode="decimal">
<input type="text" id="kurs" name="kurs" inputmode="decimal" data-nachkommastellen="6">
</div>
<div>
<label for="betrag">Betrag</label>

View file

@ -96,7 +96,7 @@ include __DIR__ . '/../layout/kopf.php';
<td class="nowrap">
<input type="hidden" form="<?= $k ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" form="<?= $k ?>" name="datum" value="<?= View::h(date('Y-m-d')) ?>">
<input type="text" form="<?= $k ?>" name="kurs" class="schmal" inputmode="decimal"
<input type="text" form="<?= $k ?>" name="kurs" class="schmal" inputmode="decimal" data-nachkommastellen="6"
placeholder="Kurs">
<button type="submit" form="<?= $k ?>" class="knopf klein">Setzen</button>
</td>

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* Taschenrechner an den Zahlenfeldern (Fix #126). Das Verhalten im Browser
* prüft tests/e2e/specs/rechner.spec.js; hier nur, dass er überall ankommt:
* eingebunden im Seitenrahmen, Symbol im Stilblatt, genaue Übernahme bei
* Stückzahl, Kurs und Zinssatz.
*/
class TaschenrechnerTest extends TestCase
{
protected string $testName = 'Test 66: Taschenrechner an Betragsfeldern';
private string $wurzel;
public function run(): void
{
$this->wurzel = dirname(__DIR__);
$this->seitenrahmenBindetRechnerEin();
$this->stilblattZeigtSymbol();
$this->skriptRechnetOhneEval();
$this->genaueFelderTragenNachkommastellen();
}
private function seitenrahmenBindetRechnerEin(): void
{
$kopf = $this->lies('src/Views/layout/kopf.php');
$this->assert(
str_contains($kopf, '<script src="/static/rechner.js" defer></script>'),
'Der Seitenrahmen bindet rechner.js ein'
);
$this->assert(
strpos($kopf, '/static/betrag.js') !== false
&& strpos($kopf, '/static/betrag.js') < strpos($kopf, '/static/rechner.js'),
'betrag.js steht vor rechner.js – der Rechner liest den Feldwert damit'
);
$this->assert(is_file($this->wurzel . '/public/static/rechner.js'), 'rechner.js liegt unter public/static');
}
private function stilblattZeigtSymbol(): void
{
$css = $this->lies('public/static/app.css');
$this->assert(
(bool)preg_match(
'/input\[type="text"\]\[inputmode="decimal"\]:not\(\[data-ohne-rechner\]\)[^{]*\{[^}]*padding-right:[^}]*background-image:\s*url\("data:image\/svg\+xml/',
$css
),
'Zahlenfelder tragen rechts das Rechnersymbol und halten den Platz dafür frei'
);
$this->assert(
(bool)preg_match('/\.rechner-tasten\s*\{[^}]*grid-template-columns:\s*repeat\(4, 1fr\)/', $css),
'Das Tastenfeld steht in vier Spalten'
);
$this->assert(
!preg_match('/\.rechner[^{]*\{[^}]*#[0-9a-f]{3,6}\b/i', $css),
'Der Rechner nimmt seine Farben aus den Custom Properties des Themas'
);
}
private function skriptRechnetOhneEval(): void
{
$js = $this->lies('public/static/rechner.js');
$this->assert(!preg_match('/\beval\s*\(|new Function\s*\(/', $js), 'Gerechnet wird ohne eval');
$this->assert(str_contains($js, "'input[type=\"text\"][inputmode=\"decimal\"]"), 'Der Rechner hängt an allen Zahlenfeldern');
foreach (["'Enter'", "'Escape'", "'C'", "'='"] as $taste) {
$this->assert(str_contains($js, $taste), "Taste $taste ist belegt");
}
}
private function genaueFelderTragenNachkommastellen(): void
{
$felder = [
'src/Views/wertpapiere/formular.php' => ['stueck' => 6, 'kurs' => 6],
'src/Views/buchungen/wertpapierfelder.php' => ['stueck' => 6, 'kurs' => 6],
'src/Views/wertpapiere/stammdaten.php' => ['kurs' => 6],
'src/Views/vertraege/kredite.php' => ['zinssatz' => 3],
];
foreach ($felder as $datei => $namen) {
$html = $this->lies($datei);
foreach ($namen as $name => $stellen) {
$this->assert(
(bool)preg_match(
'/name="' . $name . '"[^>]*data-nachkommastellen="' . $stellen . '"/',
$html
),
"$datei: „$name“ übernimmt $stellen Nachkommastellen"
);
}
}
}
private function lies(string $pfad): string
{
return (string)file_get_contents($this->wurzel . '/' . $pfad);
}
}

View file

@ -0,0 +1,146 @@
// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
async function anmelden(page) {
await page.goto('/login')
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
/* Klick auf das Rechnersymbol – es sitzt rechts im Feld. */
async function symbolKlicken(feld) {
const box = await feld.boundingBox()
if (!box) throw new Error('Feld nicht sichtbar')
await feld.page().mouse.click(box.x + box.width - 12, box.y + box.height / 2)
}
test.describe('Taschenrechner an Betragsfeldern (#126)', () => {
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Symbol im Feld öffnet den Rechner, Enter überträgt das Ergebnis', async ({ page }) => {
await page.goto('/konten/neu')
const feld = page.locator('#anfangssaldo')
await feld.fill('')
const bild = await feld.evaluate((el) => getComputedStyle(el).backgroundImage)
expect(bild).toContain('data:image/svg+xml')
await symbolKlicken(feld)
const rechner = page.locator('.rechner')
await expect(rechner).toBeVisible()
const eingabe = rechner.locator('.rechner-eingabe')
const ergebnis = rechner.locator('.rechner-ergebnis')
await expect(eingabe).toBeFocused()
// Am Feld, rechtsbündig – darunter oder, fehlt dort der Platz, darüber.
const feldBox = await feld.boundingBox()
const rechnerBox = await rechner.boundingBox()
if (!feldBox || !rechnerBox) throw new Error('nicht sichtbar')
const darunter = rechnerBox.y - (feldBox.y + feldBox.height)
const darueber = feldBox.y - (rechnerBox.y + rechnerBox.height)
expect((darunter >= 0 && darunter < 20) || (darueber >= 0 && darueber < 20)).toBe(true)
expect(Math.abs(rechnerBox.x + rechnerBox.width - (feldBox.x + feldBox.width))).toBeLessThan(2)
// Ergebnis nach jedem Tastendruck, Punkt vor Strich, Klammern.
await page.keyboard.type('12,5*2')
await expect(ergebnis).toHaveText('25')
await page.keyboard.type('+(3-1)/4')
await expect(eingabe).toHaveValue('12,5×2+(3−1)÷4')
await expect(ergebnis).toHaveText('25,5')
// Unvollständig: das letzte Ergebnis bleibt gedämpft stehen.
await page.keyboard.type('+')
await expect(ergebnis).toHaveClass(/veraltet/)
await expect(ergebnis).toHaveText('25,5')
// Buchstaben gehören nicht in eine Rechnung.
await page.keyboard.type('q')
await expect(eingabe).toHaveValue('12,5×2+(3−1)÷4+')
// Backspace links, Entf rechts der Schreibmarke, Pfeile bewegen sie.
await page.keyboard.press('Backspace')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('Delete')
await expect(eingabe).toHaveValue('12,5×2+(3−1)4')
await expect(ergebnis).toHaveClass(/fehler/)
await page.keyboard.type('*')
await expect(eingabe).toHaveValue('12,5×2+(3−1)×4')
await expect(ergebnis).toHaveText('33')
await page.keyboard.press('Enter')
await expect(rechner).toBeHidden()
await expect(feld).toHaveValue('33,00')
await expect(feld).toBeFocused()
})
test('C löscht, Escape schließt ohne Übernahme, Alt+R öffnet mit dem Feldwert', async ({ page }) => {
await page.goto('/konten/neu')
const feld = page.locator('#anfangssaldo')
await feld.fill('1.234,50')
await feld.focus()
await page.keyboard.press('Alt+r')
const rechner = page.locator('.rechner')
const eingabe = rechner.locator('.rechner-eingabe')
await expect(rechner).toBeVisible()
await expect(eingabe).toHaveValue('1234,5')
await page.keyboard.press('c')
await expect(eingabe).toHaveValue('')
await page.keyboard.type('7/0')
await expect(rechner.locator('.rechner-ergebnis')).toHaveText('Division durch 0')
await page.keyboard.press('Escape')
await expect(rechner).toBeHidden()
await expect(feld).toBeFocused()
await expect(feld).toHaveValue('1.234,50')
})
test('Tasten lassen sich auch anklicken', async ({ page }) => {
await page.goto('/konten/neu')
const feld = page.locator('#kreditlimit')
await feld.fill('')
await symbolKlicken(feld)
const rechner = page.locator('.rechner')
for (const taste of ['9', '÷', '4', '=']) {
await rechner.locator(`button[data-taste="${taste}"]`).click()
}
await expect(rechner.locator('.rechner-eingabe')).toHaveValue('2,25')
await expect(rechner.locator('.rechner-eingabe')).toBeFocused()
await rechner.getByRole('button', { name: 'Übernehmen' }).click()
await expect(feld).toHaveValue('2,25')
})
test('Im modalen Dialog: Escape schließt nur den Rechner', async ({ page }) => {
await page.goto('/vorlagen')
await page.click('#neu-knopf')
const dialog = page.locator('#neudialog')
await expect(dialog).toBeVisible()
const feld = dialog.locator('#betrag')
await feld.fill('')
await symbolKlicken(feld)
const rechner = dialog.locator('.rechner')
await expect(rechner).toBeVisible()
await page.keyboard.type('19,99*3')
await page.keyboard.press('Escape')
await expect(rechner).toBeHidden()
await expect(dialog).toBeVisible()
await symbolKlicken(feld)
await page.keyboard.type('19,99*3')
await page.keyboard.press('Enter')
await expect(dialog).toBeVisible()
await expect(feld).toHaveValue('59,97')
})
})