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
|
||||
|
||||
## Ü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)
|
||||
|
||||
- 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.leer { color: var(--text-gedaempft); font-style: italic; }
|
||||
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>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Empfänger</h3>
|
||||
<div class="empfaengerkopf">
|
||||
<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 /* Kein „for“ auf dem Label: kombobox.js legt vor das <select> ein
|
||||
|
|
@ -170,8 +181,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</form>
|
||||
<?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 !== []): ?>
|
||||
<script src="/static/kombobox.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<script src="/static/jsQR.js" defer></script>
|
||||
<script src="/static/qrimport.js" defer></script>
|
||||
|
||||
<?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