diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5de3f..db99b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Changes.md b/Changes.md index b0b0477..667b133 100644 --- a/Changes.md +++ b/Changes.md @@ -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. diff --git a/README.md b/README.md index db9f436..3eff71b 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/buchungen.md b/docs/buchungen.md index 3579e1b..562b07b 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -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 (``) 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 ``, 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 diff --git a/public/static/app.css b/public/static/app.css index 76b9fe9..ab2522e 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -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; +} diff --git a/public/static/rechner.js b/public/static/rechner.js new file mode 100644 index 0000000..0840e48 --- /dev/null +++ b/public/static/rechner.js @@ -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 }; +})(); diff --git a/public/static/reihenliste.js b/public/static/reihenliste.js index 73f9c4a..6ba30b6 100644 --- a/public/static/reihenliste.js +++ b/public/static/reihenliste.js @@ -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(); diff --git a/src/Views/buchungen/wertpapierfelder.php b/src/Views/buchungen/wertpapierfelder.php index 736da97..2921238 100644 --- a/src/Views/buchungen/wertpapierfelder.php +++ b/src/Views/buchungen/wertpapierfelder.php @@ -92,11 +92,11 @@ use Finanzmanager\View;
- +
- +

diff --git a/src/Views/layout/kopf.php b/src/Views/layout/kopf.php index fe83ab2..bc7406b 100644 --- a/src/Views/layout/kopf.php +++ b/src/Views/layout/kopf.php @@ -51,6 +51,10 @@ $istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad + + + diff --git a/src/Views/vertraege/kredite.php b/src/Views/vertraege/kredite.php index 9cbf647..ead7ab8 100644 --- a/src/Views/vertraege/kredite.php +++ b/src/Views/vertraege/kredite.php @@ -146,7 +146,7 @@ include __DIR__ . '/../layout/kopf.php';

-
@@ -224,7 +224,7 @@ include __DIR__ . '/../layout/kopf.php';
- +
diff --git a/src/Views/wertpapiere/formular.php b/src/Views/wertpapiere/formular.php index ccfc81a..eefbf0b 100644 --- a/src/Views/wertpapiere/formular.php +++ b/src/Views/wertpapiere/formular.php @@ -92,11 +92,11 @@ include __DIR__ . '/../layout/kopf.php';
- +
- +
diff --git a/src/Views/wertpapiere/stammdaten.php b/src/Views/wertpapiere/stammdaten.php index 89c7539..49ef1f0 100644 --- a/src/Views/wertpapiere/stammdaten.php +++ b/src/Views/wertpapiere/stammdaten.php @@ -96,7 +96,7 @@ include __DIR__ . '/../layout/kopf.php'; - diff --git a/tests/66_TaschenrechnerTest.php b/tests/66_TaschenrechnerTest.php new file mode 100644 index 0000000..dda65ec --- /dev/null +++ b/tests/66_TaschenrechnerTest.php @@ -0,0 +1,102 @@ +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, ''), + '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); + } +} diff --git a/tests/e2e/specs/rechner.spec.js b/tests/e2e/specs/rechner.spec.js new file mode 100644 index 0000000..0a9651c --- /dev/null +++ b/tests/e2e/specs/rechner.spec.js @@ -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') + }) +})