Fix n.n.: Überweisung erfassen – QR-Code (EPC-QR, BezahlCode) aus Bild einlesen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
26b9cb12f1
commit
970e3e086e
9 changed files with 10699 additions and 1 deletions
|
|
@ -1,5 +1,12 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Überweisung erfassen: QR-Code einlesen (01.10.2026)
|
||||||
|
|
||||||
|
- Neben „Empfänger“ steht ein Bereich, auf den sich ein Bild (z. B. Rechnungs-Screenshot) ziehen lässt. Ein Klick öffnet einen Dialog zum Einfügen aus der Zwischenablage (Strg+V) oder zur Dateiauswahl.
|
||||||
|
- Ein enthaltener QR-Code im Format EPC-QR (GiroCode) oder BezahlCode (`singlepaymentsepa`) füllt Name, IBAN, BIC, Betrag, Verwendungszweck und ggf. Ausführungstermin. Das Bild wird nur im Browser gelesen.
|
||||||
|
- Decoder: jsQR 1.4.0 (Apache-2.0) als `public/static/jsQR.js`; Fork: codeberg.org/fds2610-bot/jsQR.
|
||||||
|
- Test: `tests/e2e/specs/qrimport.spec.js` (Beispielbilder in `tests/e2e/fixtures/`).
|
||||||
|
|
||||||
## Position der „+ Neu“-Knöpfe und Erklärtexte als Tooltip (01.10.2026)
|
## Position der „+ Neu“-Knöpfe und Erklärtexte als Tooltip (01.10.2026)
|
||||||
|
|
||||||
- Buchungsvorlagen, Erinnerungen, Sparziele, Verträge und Regeln: Die Erklärzeile entfällt, der Text steht als Tooltip am aktiven Reiter. Kredite erhalten einen Tooltip am Reiter „Kredite“.
|
- Buchungsvorlagen, Erinnerungen, Sparziele, Verträge und Regeln: Die Erklärzeile entfällt, der Text steht als Tooltip am aktiven Reiter. Kredite erhalten einen Tooltip am Reiter „Kredite“.
|
||||||
|
|
|
||||||
|
|
@ -2474,3 +2474,34 @@ table.liste.reihen .anzeige:hover,
|
||||||
table.liste.reihen .anzeige:focus-visible { background: var(--flaeche-gedaempft); outline: none; }
|
table.liste.reihen .anzeige:focus-visible { background: var(--flaeche-gedaempft); outline: none; }
|
||||||
table.liste.reihen .anzeige.leer { color: var(--text-gedaempft); font-style: italic; }
|
table.liste.reihen .anzeige.leer { color: var(--text-gedaempft); font-style: italic; }
|
||||||
table.liste.reihen [hidden] { display: none; }
|
table.liste.reihen [hidden] { display: none; }
|
||||||
|
|
||||||
|
/* QR-Import im Dialog „Überweisung erfassen“: Der Bereich steht neben der
|
||||||
|
Überschrift „Empfänger“ und nimmt eine Bilddatei per Ziehen entgegen. */
|
||||||
|
.empfaengerkopf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empfaengerkopf h3 { margin: 0; }
|
||||||
|
|
||||||
|
.qrbereich {
|
||||||
|
padding: .4rem .8rem;
|
||||||
|
border: 1px dashed var(--rand-kraeftig);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche-gedaempft);
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
font-size: .9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrbereich:hover,
|
||||||
|
.qrbereich:focus-visible,
|
||||||
|
.qrbereich.ueberfahren {
|
||||||
|
border-color: var(--akzent);
|
||||||
|
background: color-mix(in srgb, var(--akzent) 8%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrmeldung { margin: .5rem 0 0; }
|
||||||
|
|
|
||||||
202
public/static/jsQR.LICENSE
Normal file
202
public/static/jsQR.LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright {yyyy} {name of copyright owner}
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
10102
public/static/jsQR.js
Normal file
10102
public/static/jsQR.js
Normal file
File diff suppressed because it is too large
Load diff
245
public/static/qrimport.js
Normal file
245
public/static/qrimport.js
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
/* QR-Import im Dialog „Überweisung erfassen“.
|
||||||
|
*
|
||||||
|
* Eine Rechnung oder ein Screenshot wird auf den Bereich neben „Empfänger“
|
||||||
|
* gezogen (oder per Klick im Dialog aus der Zwischenablage eingefügt). Das
|
||||||
|
* Bild wird im Browser nach einem QR-Code durchsucht (jsQR, siehe jsQR.js);
|
||||||
|
* enthält er einen EPC-QR (GiroCode) oder einen BezahlCode, füllt das Skript
|
||||||
|
* die Felder der Überweisung. Das Bild verlässt den Browser nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- Formate -----------------------------------------------------------
|
||||||
|
|
||||||
|
function betragNormalisieren(text) {
|
||||||
|
// „260,61“, „1.234,56“, „260.61“ → „260,61“
|
||||||
|
let zahl = text.replace(/[^\d.,]/g, '');
|
||||||
|
if (zahl.indexOf(',') >= 0) {
|
||||||
|
zahl = zahl.replace(/\./g, '');
|
||||||
|
} else if (/\.\d{3}$/.test(zahl) && zahl.split('.').length > 2) {
|
||||||
|
zahl = zahl.replace(/\./g, '');
|
||||||
|
} else {
|
||||||
|
zahl = zahl.replace('.', ',');
|
||||||
|
}
|
||||||
|
return zahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datumNormalisieren(text) {
|
||||||
|
// „02.10.2026“ oder „2026-10-02“ → „2026-10-02“ (Wert für <input type="date">)
|
||||||
|
let treffer = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
|
||||||
|
if (treffer) {
|
||||||
|
return treffer[3] + '-' + treffer[2] + '-' + treffer[1];
|
||||||
|
}
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// EPC-QR: zeilenweise, Kennung „BCD“ (European Payments Council, EPC069-12).
|
||||||
|
function epcLesen(text) {
|
||||||
|
const zeilen = text.split(/\r?\n/);
|
||||||
|
if (zeilen[0].trim() !== 'BCD' || zeilen.length < 7) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const betrag = /^[A-Z]{3}(\d+(?:\.\d{1,2})?)$/.exec((zeilen[7] || '').trim());
|
||||||
|
return {
|
||||||
|
empfaenger: (zeilen[5] || '').trim(),
|
||||||
|
iban: (zeilen[6] || '').replace(/\s+/g, ''),
|
||||||
|
bic: (zeilen[4] || '').trim(),
|
||||||
|
betrag: betrag ? betragNormalisieren(betrag[1]) : '',
|
||||||
|
// Zeile 10 = Referenz, Zeile 11 = Text; je eines von beiden ist gefüllt.
|
||||||
|
verwendungszweck: ((zeilen[10] || '') || (zeilen[9] || '')).trim(),
|
||||||
|
ausfuehrung_am: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// BezahlCode: URI „bank://singlepaymentsepa?name=…&iban=…“. Die ältere
|
||||||
|
// Variante „singlepayment“ trägt Kontonummer und BLZ statt IBAN und lässt
|
||||||
|
// sich in eine SEPA-Überweisung nicht verlässlich umrechnen.
|
||||||
|
function bezahlcodeLesen(text) {
|
||||||
|
const treffer = /^bank:\/\/([a-z]+)\?(.*)$/i.exec(text.trim());
|
||||||
|
if (!treffer || treffer[1].toLowerCase() !== 'singlepaymentsepa') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parameter = new URLSearchParams(treffer[2]);
|
||||||
|
const wert = function (name) {
|
||||||
|
return (parameter.get(name) || '').trim();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
empfaenger: wert('name'),
|
||||||
|
iban: wert('iban').replace(/\s+/g, ''),
|
||||||
|
bic: wert('bic'),
|
||||||
|
betrag: wert('amount') ? betragNormalisieren(wert('amount')) : '',
|
||||||
|
verwendungszweck: [wert('reason'), wert('reason1')].filter(Boolean).join(' '),
|
||||||
|
ausfuehrung_am: datumNormalisieren(wert('executiondate'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function zahlungLesen(text) {
|
||||||
|
return epcLesen(text) || bezahlcodeLesen(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Bild → Text -------------------------------------------------------
|
||||||
|
|
||||||
|
function qrAusBild(bild) {
|
||||||
|
// Mehrere Maßstäbe: Ein kleiner Code in einem großen Screenshot wird
|
||||||
|
// oft erst beim Vergrößern erkannt, ein sehr feiner Code beim Verkleinern.
|
||||||
|
const faktoren = [1, 0.5, 2, 0.35];
|
||||||
|
for (let i = 0; i < faktoren.length; i += 1) {
|
||||||
|
const breite = Math.round(bild.width * faktoren[i]);
|
||||||
|
const hoehe = Math.round(bild.height * faktoren[i]);
|
||||||
|
if (breite < 40 || hoehe < 40 || breite * hoehe > 36e6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const leinwand = document.createElement('canvas');
|
||||||
|
leinwand.width = breite;
|
||||||
|
leinwand.height = hoehe;
|
||||||
|
const kontext = leinwand.getContext('2d', { willReadFrequently: true });
|
||||||
|
kontext.imageSmoothingEnabled = faktoren[i] < 1;
|
||||||
|
kontext.drawImage(bild, 0, 0, breite, hoehe);
|
||||||
|
const daten = kontext.getImageData(0, 0, breite, hoehe);
|
||||||
|
const code = window.jsQR(daten.data, breite, hoehe);
|
||||||
|
if (code && code.data) {
|
||||||
|
return code.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateiAuslesen(datei) {
|
||||||
|
if (!datei.type.startsWith('image/')) {
|
||||||
|
throw new Error('Nur Bilder werden gelesen (PNG, JPG, WebP …) – bei einem PDF bitte einen Screenshot der Seite einfügen.');
|
||||||
|
}
|
||||||
|
let bild;
|
||||||
|
try {
|
||||||
|
bild = await createImageBitmap(datei);
|
||||||
|
} catch (fehler) {
|
||||||
|
throw new Error('Das Bild lässt sich nicht öffnen.');
|
||||||
|
}
|
||||||
|
const text = qrAusBild(bild);
|
||||||
|
bild.close();
|
||||||
|
if (text === null) {
|
||||||
|
throw new Error('Im Bild wurde kein QR-Code gefunden.');
|
||||||
|
}
|
||||||
|
const zahlung = zahlungLesen(text);
|
||||||
|
if (zahlung === null) {
|
||||||
|
throw new Error('Der QR-Code ist weder ein EPC-QR (GiroCode) noch ein BezahlCode für eine SEPA-Überweisung.');
|
||||||
|
}
|
||||||
|
return zahlung;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Oberfläche --------------------------------------------------------
|
||||||
|
|
||||||
|
const bereich = document.querySelector('[data-qrbereich]');
|
||||||
|
const dialog = document.getElementById('qrdialog');
|
||||||
|
if (!bereich || !dialog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meldung = document.querySelector('[data-qrmeldung]');
|
||||||
|
const dateiwahl = dialog.querySelector('[data-qrdatei]');
|
||||||
|
|
||||||
|
function melden(text, fehler) {
|
||||||
|
meldung.textContent = text;
|
||||||
|
meldung.hidden = text === '';
|
||||||
|
meldung.className = 'qrmeldung meldung ' + (fehler ? 'meldung-fehler' : 'meldung-erfolg');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uebernehmen(zahlung) {
|
||||||
|
const gesetzt = [];
|
||||||
|
Object.keys(zahlung).forEach(function (name) {
|
||||||
|
const feld = document.getElementById(name);
|
||||||
|
if (feld && zahlung[name] !== '') {
|
||||||
|
feld.value = zahlung[name];
|
||||||
|
feld.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
gesetzt.push(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Eine zuvor gewählte Adresse passt nach dem Import nicht mehr zum Namen.
|
||||||
|
const adresse = document.getElementById('adresse');
|
||||||
|
if (adresse && adresse.value !== '') {
|
||||||
|
adresse.value = '';
|
||||||
|
}
|
||||||
|
melden('QR-Code gelesen – bitte die Felder prüfen.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verarbeiten(datei) {
|
||||||
|
if (dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
melden('Wird gelesen …', false);
|
||||||
|
try {
|
||||||
|
uebernehmen(await dateiAuslesen(datei));
|
||||||
|
} catch (fehler) {
|
||||||
|
melden(fehler.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateiAusListe(liste) {
|
||||||
|
for (let i = 0; i < liste.length; i += 1) {
|
||||||
|
const datei = liste[i].kind === undefined ? liste[i] : (liste[i].kind === 'file' ? liste[i].getAsFile() : null);
|
||||||
|
if (datei) {
|
||||||
|
return datei;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffnen() {
|
||||||
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bereich.addEventListener('click', oeffnen);
|
||||||
|
bereich.addEventListener('keydown', function (ereignis) {
|
||||||
|
if (ereignis.key === 'Enter' || ereignis.key === ' ') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
oeffnen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function (name) {
|
||||||
|
bereich.addEventListener(name, function (ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
bereich.classList.add('ueberfahren');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (name) {
|
||||||
|
bereich.addEventListener(name, function () {
|
||||||
|
bereich.classList.remove('ueberfahren');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bereich.addEventListener('drop', function (ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const datei = dateiAusListe(ereignis.dataTransfer.files);
|
||||||
|
if (datei) {
|
||||||
|
verarbeiten(datei);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Einfügen: Strg+V im geöffneten Dialog.
|
||||||
|
dialog.addEventListener('paste', function (ereignis) {
|
||||||
|
const datei = dateiAusListe(ereignis.clipboardData.items);
|
||||||
|
if (datei) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
verarbeiten(datei);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Dialog nimmt auch eine Datei per Ziehen oder Dateiauswahl.
|
||||||
|
dialog.addEventListener('dragover', function (ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
});
|
||||||
|
dialog.addEventListener('drop', function (ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const datei = dateiAusListe(ereignis.dataTransfer.files);
|
||||||
|
if (datei) {
|
||||||
|
verarbeiten(datei);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dateiwahl.addEventListener('change', function () {
|
||||||
|
if (dateiwahl.files.length > 0) {
|
||||||
|
verarbeiten(dateiwahl.files[0]);
|
||||||
|
dateiwahl.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -62,7 +62,18 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="empfaengerkopf">
|
||||||
<h3>Empfänger</h3>
|
<h3>Empfänger</h3>
|
||||||
|
<?php if ($neu || $auftrag['status'] !== 'gesendet'): ?>
|
||||||
|
<?php /* Ohne Skript bleibt der Bereich unbedienbar – der Dialog öffnet erst
|
||||||
|
qrimport.js. Die Felder lassen sich in jedem Fall von Hand füllen. */ ?>
|
||||||
|
<div class="qrbereich" id="qrbereich" data-qrbereich role="button" tabindex="0"
|
||||||
|
title="Rechnung oder Screenshot mit EPC-QR oder BezahlCode hierher ziehen – oder klicken und ein Bild einfügen">
|
||||||
|
QR-Code einlesen: Bild hierher ziehen oder klicken
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="qrmeldung" data-qrmeldung role="status" hidden></p>
|
||||||
|
|
||||||
<?php if ($adressen !== []): ?>
|
<?php if ($adressen !== []): ?>
|
||||||
<?php /* Kein „for“ auf dem Label: kombobox.js legt vor das <select> ein
|
<?php /* Kein „for“ auf dem Label: kombobox.js legt vor das <select> ein
|
||||||
|
|
@ -170,8 +181,22 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<dialog class="formatdialog" id="qrdialog">
|
||||||
|
<h2>QR-Code einlesen</h2>
|
||||||
|
<p>Bild mit EPC-QR (GiroCode) oder BezahlCode aus der Zwischenablage einfügen
|
||||||
|
(<kbd>Strg</kbd> + <kbd>V</kbd>) oder eine Bilddatei wählen.</p>
|
||||||
|
<p class="feldhinweis">Das Bild wird nur im Browser gelesen und nicht hochgeladen.</p>
|
||||||
|
<label class="knopf" for="qrdatei">Bilddatei wählen …</label>
|
||||||
|
<input type="file" id="qrdatei" data-qrdatei accept="image/*" hidden>
|
||||||
|
<form method="dialog" class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<?php if ($adressen !== []): ?>
|
<?php if ($adressen !== []): ?>
|
||||||
<script src="/static/kombobox.js" defer></script>
|
<script src="/static/kombobox.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<script src="/static/jsQR.js" defer></script>
|
||||||
|
<script src="/static/qrimport.js" defer></script>
|
||||||
|
|
||||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
|
||||||
BIN
tests/e2e/fixtures/qr-bezahlcode.png
Normal file
BIN
tests/e2e/fixtures/qr-bezahlcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
tests/e2e/fixtures/qr-epc.png
Normal file
BIN
tests/e2e/fixtures/qr-epc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
86
tests/e2e/specs/qrimport.spec.js
Normal file
86
tests/e2e/specs/qrimport.spec.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// @ts-check
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { test, expect } = require('@playwright/test')
|
||||||
|
|
||||||
|
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||||
|
const PASSWORT = process.env.E2E_PASSWORT || process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||||
|
|
||||||
|
// Screenshots einer Rechnung mit QR-Code im jeweiligen Format.
|
||||||
|
const BEISPIELE = [
|
||||||
|
{ datei: 'qr-epc.png', format: 'EPC-QR' },
|
||||||
|
{ datei: 'qr-bezahlcode.png', format: 'BezahlCode' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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(/\/$/)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefeFelder(page) {
|
||||||
|
await expect(page.locator('#empfaenger')).toHaveValue('Rheinwerk Verlag GmbH')
|
||||||
|
await expect(page.locator('#iban')).toHaveValue('DE26380601863001400028')
|
||||||
|
await expect(page.locator('#bic')).toHaveValue('GENODED1BRS')
|
||||||
|
await expect(page.locator('#betrag')).toHaveValue('260,61')
|
||||||
|
await expect(page.locator('#verwendungszweck')).toHaveValue('K042-SYUAU')
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Überweisung erfassen: QR-Code einlesen', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/zahlungsverkehr/neu')
|
||||||
|
await expect(page.locator('#qrbereich')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const { datei, format } of BEISPIELE) {
|
||||||
|
test(`${format}: Dateiauswahl im Dialog füllt die Felder`, async ({ page }) => {
|
||||||
|
await page.click('#qrbereich')
|
||||||
|
await expect(page.locator('#qrdialog')).toBeVisible()
|
||||||
|
await page.setInputFiles('#qrdatei', path.join(__dirname, '..', 'fixtures', datei))
|
||||||
|
await expect(page.locator('#qrdialog')).toBeHidden()
|
||||||
|
await pruefeFelder(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${format}: Bild aus der Zwischenablage füllt die Felder`, async ({ page }) => {
|
||||||
|
const bytes = fs.readFileSync(path.join(__dirname, '..', 'fixtures', datei)).toString('base64')
|
||||||
|
await page.click('#qrbereich')
|
||||||
|
await page.evaluate(async (b64) => {
|
||||||
|
// fetch() auf data:-URLs verbietet die Content-Security-Policy (connect-src).
|
||||||
|
const blob = new Blob([Uint8Array.from(atob(b64), (z) => z.charCodeAt(0))], { type: 'image/png' })
|
||||||
|
const daten = new DataTransfer()
|
||||||
|
daten.items.add(new File([blob], 'bild.png', { type: 'image/png' }))
|
||||||
|
document.getElementById('qrdialog').dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: daten, bubbles: true, cancelable: true }))
|
||||||
|
}, bytes)
|
||||||
|
await pruefeFelder(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${format}: Ziehen auf den Bereich füllt die Felder`, async ({ page }) => {
|
||||||
|
const bytes = fs.readFileSync(path.join(__dirname, '..', 'fixtures', datei)).toString('base64')
|
||||||
|
await page.evaluate(async (b64) => {
|
||||||
|
// fetch() auf data:-URLs verbietet die Content-Security-Policy (connect-src).
|
||||||
|
const blob = new Blob([Uint8Array.from(atob(b64), (z) => z.charCodeAt(0))], { type: 'image/png' })
|
||||||
|
const daten = new DataTransfer()
|
||||||
|
daten.items.add(new File([blob], 'bild.png', { type: 'image/png' }))
|
||||||
|
document.getElementById('qrbereich').dispatchEvent(
|
||||||
|
new DragEvent('drop', { dataTransfer: daten, bubbles: true, cancelable: true }))
|
||||||
|
}, bytes)
|
||||||
|
await pruefeFelder(page)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Bild ohne QR-Code meldet einen Fehler und lässt die Felder leer', async ({ page }) => {
|
||||||
|
await page.click('#qrbereich')
|
||||||
|
await page.setInputFiles('#qrdatei', {
|
||||||
|
name: 'leer.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
// 1×1-Pixel-PNG
|
||||||
|
buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'),
|
||||||
|
})
|
||||||
|
await expect(page.locator('[data-qrmeldung]')).toContainText('kein QR-Code')
|
||||||
|
await expect(page.locator('#iban')).toHaveValue('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue