Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5cdf16c4 | ||
|
|
6ba0cdd07f |
No files matched your search
@@ -288,6 +288,27 @@ Drei Wege führen zu einem Bon:
|
||||
| **✓ Beenden** | Endbeleg | ist beendet und in der Historie |
|
||||
| **🖨** in der Historie | Nachdruck desselben Belegs | — |
|
||||
|
||||
#### Strichcode
|
||||
|
||||
Unter dem Betrag steht ein Strichcode mit dem Artikelnamen, den die Kasse führt
|
||||
(ab Werk `techniker`). Name und An/Aus stehen in den Einstellungen, also in der
|
||||
Datenbank — nicht im Code und nicht in der `.env`.
|
||||
|
||||
Der Code steht ohne Beschriftung und ohne die sonst übliche Klarschrift darunter
|
||||
(`GS H 0`): auf einem Kundenbeleg hat ein interner Artikelname nichts verloren.
|
||||
|
||||
Symbologie ist **CODE128, Zeichensatz B**. CODE39, sonst der Standard für solche
|
||||
Fälle, kennt nur Großbuchstaben und könnte `techniker` gar nicht abbilden.
|
||||
Zeichensatz B deckt den druckbaren ASCII-Bereich ab; alles darüber — Umlaute
|
||||
zumal — weist `PUT /api/settings` ab, statt es still zu verschlucken.
|
||||
|
||||
Die Modulbreite wählt `escpos.js` selbst: so breit, wie die Papierbreite es
|
||||
zulässt (Ruhezonen eingerechnet), denn breitere Balken liest ein Handscanner
|
||||
zuverlässiger. Daraus folgt die Längengrenze — bei 42 Zeichen je Zeile sind es
|
||||
17 Zeichen, bei 32 noch 12. Passt der Name nicht, druckt `receipt.js` ihn nur
|
||||
in Klarschrift: ein abgeschnittener Code würde ohnehin von keinem Scanner
|
||||
gelesen.
|
||||
|
||||
**Reihenfolge ist Absicht:** Beim Beenden wird erst gespeichert, dann gedruckt.
|
||||
Ein leerer Papierhalter ist damit ein Papierproblem und kein Datenproblem — der
|
||||
Vorgang steht in der Historie und lässt sich von dort nachdrucken. Die
|
||||
@@ -453,7 +474,7 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
||||
| `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet |
|
||||
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
|
||||
| `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen |
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` |
|
||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` |
|
||||
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||||
| `POST` | `/api/printer/test` | Probedruck |
|
||||
| `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten |
|
||||
|
||||
@@ -58,6 +58,23 @@ Zwei Wege, je nachdem, wie der Kunde geht:
|
||||
Zettel zeigt den Stand von jetzt und ist als *Zwischenstand* gekennzeichnet;
|
||||
der Timer läuft unverändert weiter.
|
||||
|
||||
### Strichcode für die Kasse
|
||||
|
||||
Unter dem Betrag steht ein Strichcode. Die Kasse scannt ihn und hat den Artikel
|
||||
sofort auf dem Bildschirm — niemand muss ihn in einer Liste suchen. Der Code
|
||||
steht ohne Beschriftung da: auf einem Kundenbeleg hat ein interner Artikelname
|
||||
nichts verloren.
|
||||
|
||||
Welcher Artikel das ist, steht in den **Einstellungen** unter *Artikel in der
|
||||
Kasse*; ab Werk `techniker`. Wird die Leistung in der Kasse umbenannt, wird der
|
||||
Name hier geändert — ohne dass jemand an die App muss. Der ganze Block lässt
|
||||
sich mit *Strichcode für die Kasse drucken* auch abschalten.
|
||||
|
||||
Zwei Grenzen setzt das Papier: Der Name darf **keine Umlaute und keine
|
||||
Sonderzeichen** enthalten (ein Strichcode kann sie nicht abbilden) und nicht
|
||||
länger sein, als quer auf die Rolle passt — bei 80 mm sind das 17 Zeichen. Beides
|
||||
sagt die App beim Speichern, statt später einen unlesbaren Code zu drucken.
|
||||
|
||||
### Wenn nichts aus dem Drucker kommt
|
||||
|
||||
Der Vorgang ist trotzdem gespeichert — die App sagt in dem Fall ausdrücklich,
|
||||
@@ -90,6 +107,9 @@ Darunter steht, was auf dem Bon gedruckt wird:
|
||||
bezahlen."
|
||||
* **Bon beim Beenden automatisch drucken** — ausgeschaltet kommt ein Bon nur
|
||||
noch über die 🖨-Knöpfe.
|
||||
* **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der
|
||||
Bon genau wie vorher.
|
||||
* **Artikel in der Kasse** — der Name, der im Strichcode steckt.
|
||||
* **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
|
||||
Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand.
|
||||
|
||||
|
||||
+65
-1
@@ -20,6 +20,24 @@ const GS = 0x1d;
|
||||
// von Node plus diese eine Sonderbehandlung.
|
||||
const CODEPAGE_WPC1252 = 16;
|
||||
|
||||
// Breite eines Zeichens in Schriftart A, in Druckpunkten. Daraus ergibt sich,
|
||||
// wie viel Platz ein Strichcode quer ueber das Papier hat.
|
||||
const ZEICHENBREITE = 12;
|
||||
|
||||
// Ruhezone links und rechts vom Strichcode, in Modulen. Ohne diesen Abstand
|
||||
// findet ein Scanner den Anfang des Codes nicht zuverlaessig.
|
||||
const RUHEZONE = 10;
|
||||
|
||||
/**
|
||||
* Wie viele Zeichen ein CODE128-Strichcode auf dieser Papierbreite noch
|
||||
* verkraeftet. Bei der schmalsten noch lesbaren Modulbreite (2 Punkte)
|
||||
* gerechnet, also der guenstigste Fall.
|
||||
*/
|
||||
function barcodeMaxLength(width = 42) {
|
||||
const module = Math.floor((width * ZEICHENBREITE) / 2) - 35 - 2 * RUHEZONE;
|
||||
return Math.max(0, Math.floor(module / 11));
|
||||
}
|
||||
|
||||
/** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */
|
||||
const ERSATZ = {
|
||||
'€': '\x80',
|
||||
@@ -131,6 +149,52 @@ class Bon {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strichcode in CODE128, Zeichensatz B.
|
||||
*
|
||||
* CODE128 statt des sonst ueblichen CODE39, weil CODE39 nur
|
||||
* Grossbuchstaben kennt - ein Artikel namens "techniker" liesse sich damit
|
||||
* nicht abbilden. Zeichensatz B deckt den druckbaren ASCII-Bereich ab und
|
||||
* wird von jedem Handscanner gelesen.
|
||||
*
|
||||
* Die Modulbreite - die Breite des schmalsten Balkens - wird so gross
|
||||
* gewaehlt, wie das Papier es zulaesst. Breitere Balken liest ein Scanner
|
||||
* zuverlaessiger, gerade auf Thermopapier, das schon einen Tag in einer
|
||||
* Hosentasche hinter sich hat.
|
||||
*
|
||||
* @param {string} text Nutzdaten, druckbares ASCII
|
||||
* @param {object} [o]
|
||||
* @param {number} [o.hoehe] Hoehe in Druckpunkten (8 Punkte = 1 mm)
|
||||
* @param {boolean} [o.klarschrift] Nutzdaten zusaetzlich lesbar darunter
|
||||
*/
|
||||
barcode(text, { hoehe = 64, klarschrift = true } = {}) {
|
||||
// Was der Zeichensatz nicht kann, wuerde der Drucker stillschweigend
|
||||
// verschlucken und einen Code ausgeben, der etwas anderes bedeutet.
|
||||
const daten = String(text == null ? '' : text).replace(/[^\x20-\x7e]/g, '');
|
||||
if (!daten) return this;
|
||||
|
||||
// Ein CODE128-Zeichen belegt 11 Module, dazu kommen Start, Pruefzeichen
|
||||
// und Stopp (zusammen 35) sowie die Ruhezone auf beiden Seiten.
|
||||
const module = 11 * daten.length + 35 + 2 * RUHEZONE;
|
||||
const modul = Math.max(2, Math.min(4, Math.floor((this.width * ZEICHENBREITE) / module)));
|
||||
|
||||
this.raw(GS, 0x68, Math.max(1, Math.min(255, hoehe))); // GS h Hoehe
|
||||
this.raw(GS, 0x77, modul); // GS w Modulbreite
|
||||
this.raw(GS, 0x66, 0); // GS f Schrift der Klarschrift
|
||||
this.raw(GS, 0x48, klarschrift ? 2 : 0); // GS H Klarschrift darunter
|
||||
|
||||
// "{B" waehlt den Zeichensatz. Eine geschweifte Klammer in den Daten
|
||||
// muss deshalb verdoppelt werden, sonst liest der Drucker sie als
|
||||
// Umschaltbefehl und der Code enthaelt am Ende etwas anderes.
|
||||
const nutz = Buffer.from('{B' + daten.split('{').join('{{'), 'latin1');
|
||||
|
||||
// Form 2 von GS k: die Laenge steht als Byte davor, statt die Daten mit
|
||||
// einem Nullbyte abzuschliessen. Nur so darf jedes Zeichen vorkommen.
|
||||
this.raw(GS, 0x6b, 73, nutz.length);
|
||||
this.parts.push(nutz);
|
||||
return this;
|
||||
}
|
||||
|
||||
feed(n = 1) {
|
||||
return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n)));
|
||||
}
|
||||
@@ -149,4 +213,4 @@ class Bon {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Bon, encodeText };
|
||||
module.exports = { Bon, encodeText, barcodeMaxLength };
|
||||
+54
-2
@@ -10,7 +10,7 @@
|
||||
* dem Zielhost gebaut wurde.
|
||||
*/
|
||||
|
||||
const { Bon } = require('./escpos');
|
||||
const { Bon, barcodeMaxLength } = require('./escpos');
|
||||
|
||||
const p2 = n => String(n).padStart(2, '0');
|
||||
|
||||
@@ -59,6 +59,7 @@ function unitCount(ms, rateInterval) {
|
||||
* @param {boolean} data.stillRunning
|
||||
* @param {string} data.header Kopfzeilen, durch \n getrennt
|
||||
* @param {string} data.footer Fusszeilen, durch \n getrennt
|
||||
* @param {string} data.barcode Artikelname fuer den Strichcode; leer = keiner
|
||||
* @param {number} data.width Zeichen je Zeile
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
@@ -117,6 +118,29 @@ function buildReceipt(data) {
|
||||
bon.line();
|
||||
bon.hr();
|
||||
|
||||
// --- Strichcode fuer die Kasse ------------------------------------------
|
||||
// Der Kassierer soll den Artikel nicht in einer Liste suchen muessen: der
|
||||
// Code enthaelt genau den Namen, unter dem die Kasse die Leistung fuehrt.
|
||||
// Bewusst ohne Beschriftung und ohne Klarschrift darunter - der Code steht
|
||||
// fuer sich, und auf einem Kundenbeleg hat ein interner Artikelname nichts
|
||||
// zu suchen.
|
||||
if (data.barcode) {
|
||||
bon.line();
|
||||
bon.align('center');
|
||||
if (data.barcode.length <= barcodeMaxLength(width)) {
|
||||
bon.barcode(data.barcode, { klarschrift: false });
|
||||
} else {
|
||||
// Breiter als das Papier. Ein abgeschnittener Code wird von keinem
|
||||
// Scanner gelesen, also lieber gar keiner: der Name allein reicht
|
||||
// zum Eintippen. Passieren kann das nur, wenn die Papierbreite
|
||||
// nachtraeglich kleiner gestellt wurde.
|
||||
bon.bold(true).line(data.barcode).bold(false);
|
||||
}
|
||||
bon.align('left');
|
||||
bon.line();
|
||||
bon.hr();
|
||||
}
|
||||
|
||||
// --- Fuss ---------------------------------------------------------------
|
||||
bon.align('center');
|
||||
if (interim) {
|
||||
@@ -140,12 +164,40 @@ function buildReceipt(data) {
|
||||
return bon.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strichcodes aus der Bytefolge herausloesen und durch ihren Inhalt ersetzen.
|
||||
*
|
||||
* Eigene Schleife statt eines Ausdrucks, weil bei "GS k" die Laenge der Daten
|
||||
* in einem Byte MITTEN in der Sequenz steht - wie weit zu springen ist, weiss
|
||||
* man also erst beim Lesen. Genau das kann ein regulaerer Ausdruck nicht.
|
||||
*/
|
||||
function stripBarcode(s) {
|
||||
let aus = '';
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
if (s.charCodeAt(i) === 0x1d && s[i + 1] === 'k' &&
|
||||
s.charCodeAt(i + 2) >= 65 && s.charCodeAt(i + 2) <= 73) {
|
||||
const laenge = s.charCodeAt(i + 3);
|
||||
const daten = s.substr(i + 4, laenge)
|
||||
.replace(/^\{[ABC]/, '') // Wahl des Zeichensatzes
|
||||
.split('{{').join('{'); // verdoppelte Klammer zuruecknehmen
|
||||
aus += `[Strichcode: ${daten}]\n`;
|
||||
i += 4 + laenge;
|
||||
continue;
|
||||
}
|
||||
aus += s[i];
|
||||
i++;
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
/** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */
|
||||
function toPlainText(buffer) {
|
||||
return buffer.toString('latin1')
|
||||
return stripBarcode(buffer.toString('latin1'))
|
||||
.replace(/\x1b@/g, '') // Zuruecksetzen
|
||||
.replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte
|
||||
.replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse
|
||||
.replace(/\x1d[hwHf][\s\S]/g, '') // GS h/w/H/f Masse des Strichcodes
|
||||
.replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt
|
||||
.replace(/\x80/g, 'EUR')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
+38
-3
@@ -18,6 +18,7 @@ require('dotenv').config();
|
||||
const oidc = require('./oidc');
|
||||
const printer = require('./printer');
|
||||
const { buildReceipt } = require('./receipt');
|
||||
const { barcodeMaxLength } = require('./escpos');
|
||||
const { provision } = require('./provision');
|
||||
|
||||
const app = express();
|
||||
@@ -152,6 +153,12 @@ async function initDatabase() {
|
||||
['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']);
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`);
|
||||
|
||||
// Strichcode fuer die Kasse: der Kassierer soll den Artikel scannen
|
||||
// koennen, statt ihn zu suchen. Der Name steht hier und nicht im Code,
|
||||
// weil er sich mit dem Artikelstamm der Kasse aendern kann.
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_enabled','1')`);
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_article','techniker')`);
|
||||
|
||||
// Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die
|
||||
// Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi
|
||||
// einrichten kann, ohne dass jemand auf den Server muss.
|
||||
@@ -213,15 +220,19 @@ async function refreshPrinterConfig() {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */
|
||||
/** Bon-Einstellungen: Kopf-/Fusstext, Strichcode und der Druck beim Beenden. */
|
||||
async function getPrintSettings() {
|
||||
const rows = await dbAll(
|
||||
`SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`);
|
||||
`SELECT key, value FROM settings WHERE key IN
|
||||
('receipt_header','receipt_footer','print_auto_on_stop',
|
||||
'receipt_barcode_enabled','receipt_barcode_article')`);
|
||||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
return {
|
||||
receiptHeader: map.receipt_header || '',
|
||||
receiptFooter: map.receipt_footer || '',
|
||||
printAutoOnStop: map.print_auto_on_stop !== '0'
|
||||
printAutoOnStop: map.print_auto_on_stop !== '0',
|
||||
receiptBarcodeEnabled: map.receipt_barcode_enabled !== '0',
|
||||
receiptBarcodeArticle: map.receipt_barcode_article || ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +288,7 @@ async function printReceipt(vorgang) {
|
||||
...vorgang,
|
||||
header: texte.receiptHeader,
|
||||
footer: texte.receiptFooter,
|
||||
barcode: texte.receiptBarcodeEnabled ? texte.receiptBarcodeArticle : '',
|
||||
width: printer.width()
|
||||
});
|
||||
const res = await printer.print(daten);
|
||||
@@ -673,6 +685,29 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
if (typeof b.printAutoOnStop === 'boolean') {
|
||||
await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0');
|
||||
}
|
||||
if (typeof b.receiptBarcodeEnabled === 'boolean') {
|
||||
await setSetting('receipt_barcode_enabled', b.receiptBarcodeEnabled ? '1' : '0');
|
||||
}
|
||||
if (typeof b.receiptBarcodeArticle === 'string') {
|
||||
const artikel = b.receiptBarcodeArticle.trim();
|
||||
// Ein Strichcode kann nur den druckbaren ASCII-Bereich darstellen. Ein
|
||||
// Umlaut wuerde stillschweigend verschwinden - dann scannt die Kasse
|
||||
// spaeter einen Artikel, den es so nicht gibt. Lieber hier sagen, warum.
|
||||
if (/[^\x20-\x7e]/.test(artikel)) {
|
||||
return res.status(400).json({
|
||||
error: 'Der Artikelname darf keine Umlaute oder Sonderzeichen enthalten - mehr kann ein Strichcode nicht abbilden.'
|
||||
});
|
||||
}
|
||||
// Zu lang heisst: der Code waere breiter als das Papier und damit
|
||||
// abgeschnitten. Ein abgeschnittener Code wird gar nicht erst gelesen.
|
||||
const max = barcodeMaxLength(printer.width());
|
||||
if (artikel.length > max) {
|
||||
return res.status(400).json({
|
||||
error: `Der Artikelname passt nicht auf die Papierbreite: höchstens ${max} Zeichen.`
|
||||
});
|
||||
}
|
||||
await setSetting('receipt_barcode_article', artikel);
|
||||
}
|
||||
|
||||
// Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die
|
||||
// Einrichtung in dieser Oberflaeche aufgesetzt wurde.
|
||||
|
||||
+16
-1
@@ -569,6 +569,9 @@ async function loadSettings() {
|
||||
document.getElementById('receiptHeader').value = settings.receiptHeader || '';
|
||||
document.getElementById('receiptFooter').value = settings.receiptFooter || '';
|
||||
document.getElementById('printAutoOnStop').checked = state.printAutoOnStop;
|
||||
document.getElementById('receiptBarcodeEnabled').checked = settings.receiptBarcodeEnabled !== false;
|
||||
document.getElementById('receiptBarcodeArticle').value = settings.receiptBarcodeArticle || '';
|
||||
toggleBarcodeField();
|
||||
|
||||
updateSettingsPreview();
|
||||
renderPrinterState(settings.printer);
|
||||
@@ -584,11 +587,16 @@ async function saveSettings() {
|
||||
const receiptHeader = document.getElementById('receiptHeader').value;
|
||||
const receiptFooter = document.getElementById('receiptFooter').value;
|
||||
const printAutoOnStop = document.getElementById('printAutoOnStop').checked;
|
||||
const receiptBarcodeEnabled = document.getElementById('receiptBarcodeEnabled').checked;
|
||||
const receiptBarcodeArticle = document.getElementById('receiptBarcodeArticle').value;
|
||||
|
||||
try {
|
||||
const rate = await api('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop })
|
||||
body: JSON.stringify({
|
||||
rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop,
|
||||
receiptBarcodeEnabled, receiptBarcodeArticle
|
||||
})
|
||||
});
|
||||
state.rate = rate;
|
||||
state.printAutoOnStop = rate.printAutoOnStop !== false;
|
||||
@@ -600,6 +608,13 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Artikelname interessiert nur, solange der Code auch gedruckt wird. */
|
||||
function toggleBarcodeField() {
|
||||
const an = document.getElementById('receiptBarcodeEnabled').checked;
|
||||
document.getElementById('barcodeArticleGroup').style.opacity = an ? '1' : '0.45';
|
||||
document.getElementById('receiptBarcodeArticle').disabled = !an;
|
||||
}
|
||||
|
||||
async function resetSettings() {
|
||||
if (!confirm('Tarif auf Standard zurücksetzen (€10 pro 10 Minuten)?')) return;
|
||||
document.getElementById('rateAmount').value = 10;
|
||||
|
||||
+17
-1
@@ -877,6 +877,22 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="receiptBarcodeEnabled" checked
|
||||
onchange="toggleBarcodeField()">
|
||||
<label for="receiptBarcodeEnabled">Strichcode für die Kasse drucken
|
||||
<small>Die Kasse scannt ihn, statt den Artikel zu suchen.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="barcodeArticleGroup">
|
||||
<label for="receiptBarcodeArticle">Artikel in der Kasse</label>
|
||||
<input type="text" id="receiptBarcodeArticle" maxlength="32"
|
||||
placeholder="techniker" autocomplete="off" spellcheck="false">
|
||||
<small>Genau der Name, unter dem die Kasse die Leistung führt.
|
||||
Keine Umlaute – der Strichcode kann sie nicht abbilden.</small>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 1.25rem;">
|
||||
<button type="button" class="btn btn-secondary" id="printerTestBtn"
|
||||
onclick="testPrint()">🖨️ Probedruck</button>
|
||||
@@ -950,6 +966,6 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/app.js?v=20260904-emoji"></script>
|
||||
<script src="/app.js?v=20260912-barcode"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user