Bon fuer die Kasse auf einem USB-Bondrucker ausgeben
Der Kunde soll mit einem Zettel zur Kasse gehen koennen, auf dem steht, was er zahlen muss. Drei Wege fuehren jetzt zu einem Bon: der Knopf "Bon" auf der Timer-Karte druckt den Zwischenstand und laesst den Timer weiterlaufen, "Beenden" druckt den Endbeleg, und in der Historie laesst sich jeder Beleg nachdrucken. Der Drucker haengt per USB an einem Raspberry Pi und ist damit vom Timer-Server aus nicht direkt erreichbar. Auf dem Pi laeuft deshalb eine kleine Bruecke (deploy/bondrucker/, nur Python-Standardbibliothek), die fertige ESC/POS-Daten per HTTP annimmt. Das Bon-Layout bleibt bewusst hier im Repo, damit Aenderungen daran nicht auf dem Pi angefasst werden muessen. Beim Beenden wird erst gespeichert und dann gedruckt. Eine leere Papierrolle ist damit ein Papierproblem und kein Datenproblem - der Vorgang steht in der Historie, die Oberflaeche sagt ausdruecklich, dass der Bon nicht gedruckt wurde, und nennt den Grund. Ohne PRINTER_HOST in der .env ist die Druckfunktion aus und die App verhaelt sich unveraendert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e83999950
commit
70756f3762
+131
-10
@@ -10,6 +10,8 @@
|
||||
const state = {
|
||||
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
|
||||
rate: { rateAmount: 10, rateInterval: 10 },
|
||||
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
|
||||
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
|
||||
tickHandle: null,
|
||||
syncHandle: null,
|
||||
history: { offset: 0, limit: 50, reachedEnd: false }
|
||||
@@ -63,7 +65,9 @@ function toast(message, kind = 'info') {
|
||||
el.textContent = message;
|
||||
el.className = 'toast show ' + kind;
|
||||
clearTimeout(toastTimeout);
|
||||
toastTimeout = setTimeout(() => { el.className = 'toast'; }, 4000);
|
||||
// Fehler laenger stehen lassen: die Meldung "Bon nicht gedruckt" enthaelt
|
||||
// den Grund und den Hinweis auf den Nachdruck - das will gelesen werden.
|
||||
toastTimeout = setTimeout(() => { el.className = 'toast'; }, kind === 'error' ? 8000 : 4000);
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
@@ -178,6 +182,8 @@ async function logout() {
|
||||
async function syncTimers() {
|
||||
const data = await api('/api/timers');
|
||||
state.rate = data.rate;
|
||||
state.printerConfigured = !!data.printerConfigured;
|
||||
state.printAutoOnStop = data.printAutoOnStop !== false;
|
||||
const now = Date.now();
|
||||
|
||||
state.timers = data.timers.map(t => ({
|
||||
@@ -261,6 +267,10 @@ function renderTimers() {
|
||||
${timer.isRunning
|
||||
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
|
||||
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
|
||||
${state.printerConfigured
|
||||
? `<button class="btn btn-secondary btn-sm" onclick="printInterim(${timer.id})"
|
||||
title="Zwischenstand drucken - der Timer läuft weiter">🖨 Bon</button>`
|
||||
: ''}
|
||||
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
|
||||
</div>
|
||||
@@ -316,21 +326,48 @@ async function resumeTimer(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwischenbon: der Kunde geht jetzt zur Kasse, der Platz bleibt aber belegt.
|
||||
* Der Timer wird dabei bewusst NICHT angefasst.
|
||||
*/
|
||||
async function printInterim(id) {
|
||||
const timer = state.timers.find(t => t.id === id);
|
||||
if (!timer) return;
|
||||
try {
|
||||
toast('Bon wird gedruckt …', 'info');
|
||||
const res = await api(`/api/timers/${id}/receipt`, { method: 'POST' });
|
||||
toast(`Bon gedruckt – ${formatEuro(res.amount)} (Timer läuft weiter)`, 'success');
|
||||
} catch (e) {
|
||||
toast('Bon konnte nicht gedruckt werden: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTimer(id) {
|
||||
const timer = state.timers.find(t => t.id === id);
|
||||
if (!timer) return;
|
||||
|
||||
const ms = currentMs(timer);
|
||||
const label = timer.customerName ? `„${timer.customerName}"` : 'Timer';
|
||||
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.`)) return;
|
||||
const bonHinweis = state.printerConfigured && state.printAutoOnStop
|
||||
? '\n\nDer Bon für die Kasse wird gedruckt.' : '';
|
||||
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.${bonHinweis}`)) return;
|
||||
|
||||
try {
|
||||
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
|
||||
await syncTimers();
|
||||
if (result.saved) {
|
||||
toast(`Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`, 'success');
|
||||
if (!result.saved) {
|
||||
return toast(result.reason || 'Nichts gespeichert.', 'info');
|
||||
}
|
||||
|
||||
const kern = `Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`;
|
||||
if (result.printed) {
|
||||
toast(kern + ' · Bon gedruckt', 'success');
|
||||
} else if (result.printError) {
|
||||
// Bewusst als Fehler und mit Nennung des Nachdrucks: der Vorgang ist
|
||||
// gespeichert, es fehlt nur das Papier.
|
||||
toast(`${kern}\nBon NICHT gedruckt (${result.printError}) – Nachdruck über die Historie.`, 'error');
|
||||
} else {
|
||||
toast(result.reason || 'Nichts gespeichert.', 'info');
|
||||
toast(kern, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
|
||||
@@ -426,6 +463,10 @@ function renderHistory(data, reset) {
|
||||
</div>
|
||||
<div class="history-item-right">
|
||||
<div class="history-item-amount">${formatEuro(s.amount)}</div>
|
||||
${state.printerConfigured
|
||||
? `<button class="btn btn-secondary btn-sm" onclick="reprintSession(${s.id})"
|
||||
title="Bon noch einmal drucken">🖨</button>`
|
||||
: ''}
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -448,6 +489,17 @@ async function loadMoreHistory() {
|
||||
await loadHistory(false);
|
||||
}
|
||||
|
||||
/** Nachdruck aus der Historie - Papier war leer, Bon verlegt, Kunde will eine Kopie. */
|
||||
async function reprintSession(id) {
|
||||
try {
|
||||
toast('Bon wird gedruckt …', 'info');
|
||||
await api(`/api/sessions/${id}/receipt`, { method: 'POST' });
|
||||
toast('Bon nachgedruckt', 'success');
|
||||
} catch (e) {
|
||||
toast('Nachdruck fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(id) {
|
||||
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
|
||||
try {
|
||||
@@ -502,11 +554,20 @@ document.getElementById('filterName').addEventListener('input', (() => {
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const rate = await api('/api/settings');
|
||||
state.rate = rate;
|
||||
document.getElementById('rateAmount').value = rate.rateAmount;
|
||||
document.getElementById('rateInterval').value = rate.rateInterval;
|
||||
const settings = await api('/api/settings');
|
||||
state.rate = settings;
|
||||
state.printerConfigured = !!(settings.printer && settings.printer.configured);
|
||||
state.printAutoOnStop = settings.printAutoOnStop !== false;
|
||||
|
||||
document.getElementById('rateAmount').value = settings.rateAmount;
|
||||
document.getElementById('rateInterval').value = settings.rateInterval;
|
||||
document.getElementById('receiptHeader').value = settings.receiptHeader || '';
|
||||
document.getElementById('receiptFooter').value = settings.receiptFooter || '';
|
||||
document.getElementById('printAutoOnStop').checked = state.printAutoOnStop;
|
||||
|
||||
updateSettingsPreview();
|
||||
renderPrinterState(settings.printer);
|
||||
refreshPrinterStatus();
|
||||
} catch (e) {
|
||||
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
|
||||
}
|
||||
@@ -515,13 +576,17 @@ async function loadSettings() {
|
||||
async function saveSettings() {
|
||||
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
|
||||
const rateInterval = parseInt(document.getElementById('rateInterval').value);
|
||||
const receiptHeader = document.getElementById('receiptHeader').value;
|
||||
const receiptFooter = document.getElementById('receiptFooter').value;
|
||||
const printAutoOnStop = document.getElementById('printAutoOnStop').checked;
|
||||
|
||||
try {
|
||||
const rate = await api('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rateAmount, rateInterval })
|
||||
body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop })
|
||||
});
|
||||
state.rate = rate;
|
||||
state.printAutoOnStop = rate.printAutoOnStop !== false;
|
||||
renderTimers();
|
||||
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
|
||||
showTimer();
|
||||
@@ -537,6 +602,62 @@ async function resetSettings() {
|
||||
await saveSettings();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bondrucker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Zeigt in den Einstellungen, ob und wo ein Drucker haengt. */
|
||||
function renderPrinterState(info, live) {
|
||||
const el = document.getElementById('printerState');
|
||||
const testBtn = document.getElementById('printerTestBtn');
|
||||
if (!el) return;
|
||||
|
||||
if (!info || !info.configured) {
|
||||
el.className = 'printer-state off';
|
||||
el.textContent = 'Kein Bondrucker eingerichtet. Einzurichten in der .env des Servers (PRINTER_HOST) – siehe deploy/bondrucker/README.md.';
|
||||
if (testBtn) testBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (testBtn) testBtn.disabled = false;
|
||||
const ziel = `${info.host}:${info.port}`;
|
||||
|
||||
if (live === undefined) {
|
||||
el.className = 'printer-state';
|
||||
el.textContent = `Drucker eingerichtet: ${ziel} – Zustand wird geprüft …`;
|
||||
} else if (live.ok) {
|
||||
el.className = 'printer-state ok';
|
||||
const geraet = live.printer && live.printer.device ? ` (${live.printer.device})` : '';
|
||||
el.textContent = `Drucker bereit: ${ziel}${geraet}`;
|
||||
} else {
|
||||
el.className = 'printer-state error';
|
||||
el.textContent = `Drucker nicht erreichbar: ${ziel} – ${live.error || 'unbekannter Fehler'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPrinterStatus() {
|
||||
try {
|
||||
const status = await api('/api/printer/status');
|
||||
renderPrinterState({ configured: status.configured, host: status.host, port: status.port }, status);
|
||||
} catch (e) {
|
||||
renderPrinterState({ configured: state.printerConfigured }, { ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function testPrint() {
|
||||
const btn = document.getElementById('printerTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await api('/api/printer/test', { method: 'POST' });
|
||||
toast('Probebon gedruckt', 'success');
|
||||
} catch (e) {
|
||||
toast('Probedruck fehlgeschlagen: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
refreshPrinterStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettingsPreview() {
|
||||
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
|
||||
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
|
||||
|
||||
+105
-4
@@ -219,7 +219,8 @@
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"] {
|
||||
input[type="date"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.125rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -231,14 +232,16 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4d35a9;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35);
|
||||
}
|
||||
|
||||
input::placeholder { color: rgba(255, 255, 255, 0.35); }
|
||||
input::placeholder,
|
||||
textarea::placeholder { color: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
/* Datumsfelder im Dark Theme lesbar halten */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
|
||||
@@ -551,6 +554,66 @@
|
||||
margin-top: 1rem !important;
|
||||
}
|
||||
|
||||
/* ---------- Bondrucker ---------- */
|
||||
|
||||
.settings-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin: 2rem 0;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-section > p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-section textarea {
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-group + .form-group { margin-top: 1.25rem; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
accent-color: #6d4aff;
|
||||
}
|
||||
|
||||
.checkbox-row label { font-weight: 500; cursor: pointer; }
|
||||
.checkbox-row small { display: block; color: rgba(255, 255, 255, 0.5); font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
|
||||
.printer-state {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.printer-state.ok { border-left-color: #22c55e; }
|
||||
.printer-state.error { border-left-color: #ef4444; }
|
||||
.printer-state.off { border-left-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
.settings-actions { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.settings-actions .btn { flex: 1; padding: 1rem; }
|
||||
|
||||
@@ -582,6 +645,8 @@
|
||||
max-width: 90vw;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
/* Meldungen zum Bondruck sind zweizeilig - Umbrueche sollen stehen bleiben. */
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
@@ -716,6 +781,42 @@
|
||||
<p class="preview-examples" id="previewExamples"></p>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>🖨️ Bon für die Kasse</h3>
|
||||
<p>Was auf dem Zettel steht, den der Kunde mit zur Kasse nimmt.
|
||||
Betrag, Zeiten und Tarif kommen automatisch dazu.</p>
|
||||
|
||||
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="receiptHeader">Kopfzeilen</label>
|
||||
<textarea id="receiptHeader" maxlength="240" rows="3"
|
||||
placeholder="Handy-Insel Hauptstraße 12, 12345 Musterstadt Tel. 01234 / 56789"></textarea>
|
||||
<small>Erste Zeile wird groß gedruckt. Eine Zeile je Zeilenumbruch, maximal 240 Zeichen.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="receiptFooter">Fußzeilen</label>
|
||||
<textarea id="receiptFooter" maxlength="240" rows="2"
|
||||
placeholder="Bitte an der Kasse bezahlen."></textarea>
|
||||
<small>Steht unter dem Betrag, maximal 240 Zeichen.</small>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="printAutoOnStop" checked>
|
||||
<label for="printAutoOnStop">Bon beim Beenden automatisch drucken
|
||||
<small>Aus: der Bon wird nur über die Knöpfe 🖨 gedruckt.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 1.25rem;">
|
||||
<button type="button" class="btn btn-secondary" id="printerTestBtn"
|
||||
onclick="testPrint()">🖨️ Probedruck</button>
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="refreshPrinterStatus()">🔄 Drucker prüfen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="btn">💾 Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
|
||||
@@ -733,6 +834,6 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/app.js?v=20260803-sso"></script>
|
||||
<script src="/app.js?v=20260904-bon"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user