Die Einrichtung war Handarbeit: Dateien per scp auf den Pi, dort install.sh mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch niemand mehr weiss. Jetzt macht das ein Knopf unter "Einstellungen": Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an, prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen nach. Jeder Schritt steht mit Ergebnis im Protokoll. Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim allerersten Start, wie RATE_PER_10MIN auch. Zwei Fehler, die beim Testen der Einrichtung auffielen: install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart. Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1), und die Einrichtung prueft das mit. Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal geht. Neu dabei: ssh2, reines JavaScript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
949 lines
34 KiB
HTML
949 lines
34 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Timer - Zeiterfassung</title>
|
||
<link rel="icon" type="image/jpeg" href="/favicon.jpg">
|
||
<style>
|
||
/* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit.
|
||
Der System-Font-Stack sieht praktisch identisch aus (Segoe UI /
|
||
Roboto / SF Pro). Wer Inter zwingend will: siehe DEPLOY.md. */
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d44 50%, #1a1a2e 100%);
|
||
min-height: 100vh;
|
||
color: #ffffff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: -50%;
|
||
left: -50%;
|
||
width: 200%;
|
||
height: 200%;
|
||
background: radial-gradient(circle, rgba(45, 27, 105, 0.1) 0%, transparent 50%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.3; }
|
||
50% { transform: scale(1.1) rotate(180deg); opacity: 0.5; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
body::before { animation: none; }
|
||
.view { animation: none; }
|
||
}
|
||
|
||
.container {
|
||
position: relative;
|
||
z-index: 1;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
header {
|
||
padding: 1.5rem 2rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
background: rgba(26, 26, 46, 0.8);
|
||
border-bottom: 1px solid rgba(45, 27, 105, 0.3);
|
||
}
|
||
|
||
.logo {
|
||
height: 50px;
|
||
width: auto;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-user {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.45rem 0.85rem;
|
||
border-radius: 10px;
|
||
background: rgba(45, 27, 105, 0.35);
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.header-user::before { content: '👤'; }
|
||
.header-user:empty { display: none; }
|
||
|
||
.btn {
|
||
padding: 0.625rem 1.5rem;
|
||
background: linear-gradient(135deg, #2D1B69, #3d2589);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 0.95rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-family: inherit;
|
||
box-shadow: 0 4px 15px rgba(45, 27, 105, 0.3);
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(45, 27, 105, 0.5);
|
||
background: linear-gradient(135deg, #3d2589, #4d35a9);
|
||
}
|
||
|
||
.btn:active { transform: translateY(0); }
|
||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
|
||
.btn:focus-visible {
|
||
outline: 2px solid #a8a8ff;
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 0.5rem 1rem;
|
||
font-size: 0.875rem;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.btn-danger { background: linear-gradient(135deg, #8B1538, #a01848); }
|
||
.btn-danger:hover { background: linear-gradient(135deg, #a01848, #b02058); }
|
||
.btn-start { background: linear-gradient(135deg, #10b981, #059669); }
|
||
.btn-start:hover { background: linear-gradient(135deg, #059669, #047857); }
|
||
.btn-stop { background: linear-gradient(135deg, #f59e0b, #d97706); }
|
||
.btn-stop:hover { background: linear-gradient(135deg, #d97706, #b45309); }
|
||
|
||
main {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.view {
|
||
display: none;
|
||
width: 100%;
|
||
max-width: 1200px;
|
||
animation: fadeIn 0.4s ease-out;
|
||
}
|
||
|
||
.view.active { display: block; }
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* ---------- Login ---------- */
|
||
|
||
#loginView { display: none; }
|
||
#loginView.active { display: flex; align-items: center; justify-content: center; min-height: 70vh; }
|
||
|
||
.login-container {
|
||
max-width: 450px;
|
||
width: 100%;
|
||
margin: 0 auto;
|
||
background: rgba(26, 26, 46, 0.8);
|
||
border-radius: 20px;
|
||
padding: 3rem;
|
||
border: 1px solid rgba(45, 27, 105, 0.3);
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.login-logo {
|
||
width: 120px;
|
||
height: 120px;
|
||
margin: 0 auto 2rem;
|
||
display: block;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.login-container h1 {
|
||
text-align: center;
|
||
margin-bottom: 2rem;
|
||
font-size: 1.8rem;
|
||
font-weight: 600;
|
||
background: linear-gradient(135deg, #ffffff, #a8a8ff);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.form-group { margin-bottom: 1.5rem; }
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="password"],
|
||
input[type="number"],
|
||
input[type="date"],
|
||
textarea {
|
||
width: 100%;
|
||
padding: 0.875rem 1.125rem;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
font-size: 1rem;
|
||
color: white;
|
||
font-family: inherit;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
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,
|
||
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; }
|
||
|
||
.error-message {
|
||
color: #ff8787;
|
||
font-size: 0.875rem;
|
||
margin-top: 0.5rem;
|
||
display: none;
|
||
}
|
||
|
||
.error-message.show { display: block; }
|
||
|
||
/* ---------- Timer-Uebersicht ---------- */
|
||
|
||
.timer-toolbar {
|
||
background: rgba(26, 26, 46, 0.6);
|
||
border-radius: 20px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 2rem;
|
||
border: 1px solid rgba(45, 27, 105, 0.3);
|
||
}
|
||
|
||
.timer-toolbar form {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.timer-toolbar input {
|
||
flex: 1;
|
||
min-width: 220px;
|
||
}
|
||
|
||
.timer-toolbar .btn { white-space: nowrap; }
|
||
|
||
.rate-hint {
|
||
margin-top: 1rem;
|
||
font-size: 0.9rem;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.timer-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
.timer-card {
|
||
background: rgba(26, 26, 46, 0.6);
|
||
border-radius: 20px;
|
||
padding: 1.75rem;
|
||
border: 2px solid rgba(45, 27, 105, 0.4);
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||
text-align: center;
|
||
transition: border-color 0.3s ease;
|
||
}
|
||
|
||
.timer-card.running { border-color: rgba(16, 185, 129, 0.45); }
|
||
.timer-card.paused { border-color: rgba(245, 158, 11, 0.45); }
|
||
|
||
.timer-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.timer-name {
|
||
flex: 1;
|
||
padding: 0.5rem 0.75rem !important;
|
||
font-size: 0.95rem !important;
|
||
font-weight: 500;
|
||
text-align: left;
|
||
}
|
||
|
||
.timer-badge {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
padding: 0.35rem 0.6rem;
|
||
border-radius: 6px;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.timer-card.running .timer-badge { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
|
||
.timer-card.paused .timer-badge { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
|
||
|
||
.timer-card-time {
|
||
font-size: 3.2rem;
|
||
font-weight: 300;
|
||
letter-spacing: 0.03em;
|
||
font-variant-numeric: tabular-nums;
|
||
margin-bottom: 0.75rem;
|
||
background: linear-gradient(135deg, #ffffff, #c8c8ff);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.timer-card-amount {
|
||
font-size: 1.8rem;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
background: linear-gradient(135deg, #2D1B69, #3d2589);
|
||
padding: 0.6rem 1.5rem;
|
||
border-radius: 12px;
|
||
display: inline-block;
|
||
min-width: 140px;
|
||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.timer-card-since {
|
||
margin-top: 0.75rem;
|
||
font-size: 0.8rem;
|
||
color: rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
.timer-card-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
justify-content: center;
|
||
margin-top: 1.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ---------- Historie ---------- */
|
||
|
||
.history-container,
|
||
.settings-container {
|
||
background: rgba(26, 26, 46, 0.6);
|
||
border-radius: 20px;
|
||
padding: 2rem;
|
||
border: 1px solid rgba(45, 27, 105, 0.3);
|
||
}
|
||
|
||
.settings-container { max-width: 700px; margin: 0 auto; padding: 2.5rem; }
|
||
|
||
.history-container h2,
|
||
.settings-container h2 {
|
||
margin-bottom: 1.5rem;
|
||
font-size: 2rem;
|
||
font-weight: 600;
|
||
background: linear-gradient(135deg, #ffffff, #a8a8ff);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
padding-bottom: 1.5rem;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.filter-field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||
|
||
.filter-field label {
|
||
font-size: 0.8rem;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.filter-field input { min-width: 150px; padding: 0.6rem 0.9rem !important; font-size: 0.9rem !important; }
|
||
|
||
.filter-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||
|
||
.history-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 1.5rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.stat-card {
|
||
background: rgba(45, 27, 105, 0.2);
|
||
padding: 1.5rem;
|
||
border-radius: 15px;
|
||
border: 1px solid rgba(45, 27, 105, 0.3);
|
||
}
|
||
|
||
.stat-card h3 {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
margin-bottom: 0.5rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.stat-card p {
|
||
font-size: 1.9rem;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.history-list { max-height: 55vh; overflow-y: auto; }
|
||
|
||
.history-item {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
padding: 1.1rem 1.25rem;
|
||
border-radius: 12px;
|
||
margin-bottom: 0.75rem;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
transition: all 0.25s ease;
|
||
}
|
||
|
||
.history-item:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-color: rgba(45, 27, 105, 0.4);
|
||
}
|
||
|
||
.history-item-info { flex: 1; min-width: 0; }
|
||
|
||
.history-item-name {
|
||
display: block;
|
||
font-size: 1.05rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.15rem;
|
||
}
|
||
|
||
.history-item-name.muted { color: rgba(255, 255, 255, 0.4); font-weight: 400; font-style: italic; }
|
||
|
||
.history-item-date { font-size: 0.85rem; color: rgba(255, 255, 255, 0.55); }
|
||
|
||
.history-item-duration {
|
||
font-size: 1.05rem;
|
||
font-weight: 500;
|
||
font-variant-numeric: tabular-nums;
|
||
margin-top: 0.15rem;
|
||
}
|
||
|
||
.history-item-right { display: flex; align-items: center; gap: 0.75rem; }
|
||
|
||
.history-item-amount {
|
||
font-size: 1.4rem;
|
||
font-weight: 600;
|
||
background: linear-gradient(135deg, #2D1B69, #3d2589);
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 8px;
|
||
min-width: 100px;
|
||
text-align: center;
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.history-footer { text-align: center; margin-top: 1rem; }
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.empty-state-icon { font-size: 4rem; margin-bottom: 1rem; opacity: 0.3; }
|
||
|
||
/* ---------- Einstellungen ---------- */
|
||
|
||
.settings-description { color: rgba(255, 255, 255, 0.7); margin-bottom: 2rem; font-size: 0.95rem; }
|
||
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1.5rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.form-group small {
|
||
display: block;
|
||
margin-top: 0.5rem;
|
||
font-size: 0.8rem;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.settings-preview {
|
||
background: rgba(45, 27, 105, 0.2);
|
||
padding: 1.5rem;
|
||
border-radius: 15px;
|
||
margin-bottom: 2rem;
|
||
border: 1px solid rgba(45, 27, 105, 0.3);
|
||
text-align: center;
|
||
}
|
||
|
||
.settings-preview h3 {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
margin-bottom: 1rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.settings-preview p { font-size: 1.7rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||
|
||
.preview-examples {
|
||
font-size: 0.95rem !important;
|
||
font-weight: 400 !important;
|
||
color: rgba(255, 255, 255, 0.7) !important;
|
||
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);
|
||
}
|
||
|
||
.setup-box {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.setup-grid {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.setup-grid .form-group + .form-group { margin-top: 0; }
|
||
|
||
@media (max-width: 600px) {
|
||
.setup-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.setup-hint {
|
||
font-size: 0.85rem;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
margin-top: 1rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
|
||
sichtbar ist, wie weit es gekommen ist. */
|
||
.setup-log {
|
||
margin-top: 1rem;
|
||
font-size: 0.85rem;
|
||
display: none;
|
||
}
|
||
|
||
.setup-log.show { display: block; }
|
||
|
||
.setup-step {
|
||
display: flex;
|
||
gap: 0.65rem;
|
||
padding: 0.5rem 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.setup-step:last-child { border-bottom: none; }
|
||
.setup-step-icon { flex-shrink: 0; }
|
||
.setup-step-name { font-weight: 600; }
|
||
|
||
.setup-step-detail {
|
||
color: rgba(255, 255, 255, 0.6);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||
font-size: 0.78rem;
|
||
margin-top: 0.3rem;
|
||
max-height: 11rem;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.setup-step.fail .setup-step-name { color: #f87171; }
|
||
|
||
.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; }
|
||
|
||
.settings-info {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
padding: 1rem;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.settings-info p { font-size: 0.875rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
|
||
|
||
/* ---------- Toast ---------- */
|
||
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(200%);
|
||
background: rgba(26, 26, 46, 0.97);
|
||
border: 1px solid rgba(45, 27, 105, 0.6);
|
||
color: #fff;
|
||
padding: 1rem 1.75rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||
z-index: 100;
|
||
transition: transform 0.35s ease, opacity 0.35s ease;
|
||
opacity: 0;
|
||
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; }
|
||
.toast.success { border-color: rgba(16, 185, 129, 0.7); }
|
||
.toast.error { border-color: rgba(220, 38, 38, 0.8); }
|
||
|
||
/* ---------- Mobil ---------- */
|
||
|
||
@media (max-width: 768px) {
|
||
header { padding: 1rem; }
|
||
main { padding: 1rem; }
|
||
.logo { height: 38px; }
|
||
.header-actions { gap: 0.5rem; width: 100%; }
|
||
.header-actions .btn { flex: 1; padding: 0.6rem 0.75rem; font-size: 0.85rem; }
|
||
.login-container { padding: 2rem 1.5rem; }
|
||
.timer-grid { grid-template-columns: 1fr; }
|
||
.timer-card-time { font-size: 2.6rem; }
|
||
.settings-grid { grid-template-columns: 1fr; }
|
||
.settings-actions { flex-direction: column; }
|
||
.history-item { flex-direction: column; align-items: stretch; text-align: center; }
|
||
.history-item-right { justify-content: center; }
|
||
.filter-field, .filter-field input { width: 100%; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header id="header" style="display: none;">
|
||
<img src="/logo.png" alt="Logo" class="logo">
|
||
<div class="header-actions">
|
||
<span id="currentUser" class="header-user" title="Angemeldet"></span>
|
||
<button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button>
|
||
<button class="btn btn-secondary" onclick="showHistory()">📊 Historie</button>
|
||
<button class="btn btn-secondary" onclick="showSettings()">⚙️ Einstellungen</button>
|
||
<button class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- Anmeldung -->
|
||
<div id="loginView" class="view active">
|
||
<div class="login-container">
|
||
<img src="/favicon.jpg" alt="" class="login-logo">
|
||
<h1>Zeiterfassung</h1>
|
||
|
||
<!-- Anmeldung ausschliesslich ueber Authentik (SSO) -->
|
||
<a id="ssoLoginBtn" href="/auth/login" class="btn"
|
||
style="width: 100%; padding: 1rem; text-decoration: none; box-sizing: border-box;">🔐 Anmelden mit Authentik</a>
|
||
<div class="error-message" id="loginError" style="margin-top: 1rem;">
|
||
Anmeldung derzeit nicht möglich – Authentik ist nicht erreichbar.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timer-Uebersicht -->
|
||
<div id="timerView" class="view">
|
||
<div class="timer-toolbar">
|
||
<form id="newTimerForm">
|
||
<input type="text" id="newTimerName" maxlength="80"
|
||
placeholder="Name oder Platz (optional) - z. B. „Tisch 2" oder „Herr Müller"">
|
||
<button type="submit" class="btn btn-start">▶ Neuer Timer</button>
|
||
</form>
|
||
<div class="rate-hint" id="rateHint">Tarif wird geladen …</div>
|
||
</div>
|
||
|
||
<div class="timer-grid" id="timerList"></div>
|
||
|
||
<div class="empty-state" id="timerEmpty" style="display: none;">
|
||
<div class="empty-state-icon">⏱️</div>
|
||
<p>Kein Timer läuft gerade.<br>Oben einen neuen starten.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Historie -->
|
||
<div id="historyView" class="view">
|
||
<div class="history-container">
|
||
<h2>📊 Historie</h2>
|
||
|
||
<div class="filter-bar">
|
||
<div class="filter-field">
|
||
<label for="filterFrom">Von</label>
|
||
<input type="date" id="filterFrom">
|
||
</div>
|
||
<div class="filter-field">
|
||
<label for="filterTo">Bis</label>
|
||
<input type="date" id="filterTo">
|
||
</div>
|
||
<div class="filter-field">
|
||
<label for="filterName">Name</label>
|
||
<input type="text" id="filterName" placeholder="Kunde / Platz">
|
||
</div>
|
||
<div class="filter-actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="filterToday()">Heute</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="filterThisMonth()">Dieser Monat</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="resetFilter()">Alle</button>
|
||
<button class="btn btn-sm" onclick="exportCsv()">⬇ CSV-Export</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="history-stats" id="historyStats"></div>
|
||
<div class="history-list" id="historyList"></div>
|
||
<div class="history-footer">
|
||
<button class="btn btn-secondary" id="historyMore"
|
||
onclick="loadMoreHistory()" style="display: none;">Weitere laden</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Einstellungen -->
|
||
<div id="settingsView" class="view">
|
||
<div class="settings-container">
|
||
<h2>⚙️ Einstellungen</h2>
|
||
<p class="settings-description">Der Tarif gilt für alle Geräte gemeinsam.</p>
|
||
|
||
<form id="settingsForm">
|
||
<div class="settings-grid">
|
||
<div class="form-group">
|
||
<label for="rateAmount">Betrag (€)</label>
|
||
<input type="number" id="rateAmount" min="0.5" max="10000" step="0.5" value="10" required>
|
||
<small>Euro pro Intervall</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="rateInterval">Intervall (Minuten)</label>
|
||
<input type="number" id="rateInterval" min="1" max="600" step="1" value="10" required>
|
||
<small>Jede angefangene Einheit wird voll berechnet</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-preview">
|
||
<h3>Vorschau</h3>
|
||
<p id="settingsPreview">€10 pro angefangene 10 Minuten</p>
|
||
<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>
|
||
<button type="button" class="btn btn-secondary"
|
||
onclick="toggleSetup()" id="setupToggleBtn">🔧 Drucker einrichten</button>
|
||
</div>
|
||
|
||
<div class="setup-box" id="setupBox" style="display: none;">
|
||
<h3 style="margin-bottom: 0.35rem;">Neuen Drucker-Pi einrichten</h3>
|
||
<p style="color: rgba(255,255,255,0.7); font-size: 0.9rem; margin-bottom: 1.25rem;">
|
||
Richtet den Druckdienst auf einem frisch aufgesetzten Raspberry Pi
|
||
vollständig ein und übernimmt ihn anschließend. Der Pi braucht nur
|
||
SSH, Python 3 und den Drucker am USB-Anschluss.
|
||
</p>
|
||
|
||
<div class="setup-grid">
|
||
<div class="form-group">
|
||
<label for="setupHost">Adresse des Pi</label>
|
||
<input type="text" id="setupHost" placeholder="192.168.8.157" autocomplete="off">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="setupPort">SSH-Port</label>
|
||
<input type="number" id="setupPort" value="22" min="1" max="65535">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="setup-grid" style="margin-top: 1rem;">
|
||
<div class="form-group">
|
||
<label for="setupUser">Benutzer</label>
|
||
<input type="text" id="setupUser" value="pi" autocomplete="off">
|
||
<small>Muss auf dem Pi sudo dürfen</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="setupPassword">Passwort</label>
|
||
<input type="password" id="setupPassword" autocomplete="new-password">
|
||
<small>Wird auch für sudo verwendet</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-actions" style="margin-top: 1.25rem; margin-bottom: 0;">
|
||
<button type="button" class="btn" id="setupStartBtn"
|
||
onclick="startProvision()">🚀 Jetzt einrichten</button>
|
||
</div>
|
||
|
||
<p class="setup-hint">
|
||
Das Passwort wird einmal verwendet und danach verworfen — gespeichert
|
||
wird nur ein neu erzeugtes Drucker-Token. Ein vorhandener Druckdienst
|
||
auf demselben Pi wird dabei überschrieben.
|
||
</p>
|
||
|
||
<div class="setup-log" id="setupLog"></div>
|
||
</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>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="settings-info">
|
||
<p><strong>Hinweis:</strong> Eine Tarifänderung gilt ab sofort für laufende und neue Timer.
|
||
Bereits gespeicherte Einträge in der Historie behalten den Tarif, der beim Beenden galt.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script src="/app.js?v=20260904-setup"></script>
|
||
</body>
|
||
</html>
|