Timer-App unter Versionsverwaltung stellen

Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst
timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express,
SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) -
der frueher vorhandene Passwort-Login ist entfernt.

Nicht im Repo, bewusst:
- backend/.env und die .env-Sicherung: enthalten Session-Secret und die
  Authentik-Zugangsdaten. Vorlage ist backend/.env.example.
- data/: die Datenbank mit den echten Zeiterfassungen.
- node_modules/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 17:47:07 +02:00
co-authored by Claude Opus 5
commit a7e77793a8
16 changed files with 5192 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Abhaengigkeiten
node_modules/
# Konfiguration mit Zugangsdaten - NIEMALS einchecken.
# Vorlage ist backend/.env.example
.env
.env.*
!.env.example
# Laufzeitdaten: Datenbank
data/
*.log
# Interne Unterlagen
.claude/
.DS_Store
+477
View File
@@ -0,0 +1,477 @@
# Timer App — Übergabe- und Installationsdokument
**Für die Claude-Code-Instanz, die diese App in die Firmeninfrastruktur einbaut.**
Stand: 03.08.2026 · Version 2.0 · Getestet auf Debian 13, Node 20.19.2
---
## 0. Das Wichtigste in 60 Sekunden
Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung,
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).
* **Keine externen Dienste.** Kein Cloud-Backend, keine API-Keys, keine Telemetrie,
keine CDN-Einbindung. Die App macht **keine ausgehenden Netzwerkverbindungen**.
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
Vanilla JS im Frontend, Express im Backend.
* **Abhängigkeiten:** 6 npm-Pakete, alle etabliert (express, express-session,
express-rate-limit, sqlite3, bcrypt, dotenv).
Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
scheitern.
---
## 1. Was die App fachlich tut
Ein Mitarbeiter startet am Tresen einen Timer, optional mit einem Namen
(„Tisch 2", „Herr Müller", „Reparatur Werkbank"). Die App zählt die Zeit und
rechnet daraus laufend einen Betrag aus. Beim Beenden wandert der Vorgang in die
Historie und kann später als CSV exportiert werden.
**Abrechnungsregel:** Jede *angefangene* Einheit wird voll berechnet.
Standard ist 10 € pro 10 Minuten, im Menü „Einstellungen" änderbar.
| Dauer | Betrag (bei 10 €/10 Min) |
|---|---|
| 1 Sekunde | 10 € |
| 10 Minuten | 10 € |
| 10 Min 1 Sek | 20 € |
| 25 Minuten | 30 € |
| 60 Minuten | 60 € |
Diese Regel ist bewusst so — sie war die fachliche Vorgabe. Falls im Betrieb
minutengenau abgerechnet werden soll, ist das eine Änderung an genau einer
Funktion (`calcAmount`, siehe Abschnitt 7).
---
## 2. Architektur — und warum sie so ist
### Timer leben auf dem Server, nicht im Browser
Das ist die zentrale Designentscheidung und der Grund für die meisten
Endpunkte. Ein laufender Timer ist eine Zeile in der Tabelle `active_timers`,
kein JavaScript-Objekt im Browser.
Was das im Ladenalltag bedeutet:
| Situation | Verhalten |
|---|---|
| Tab wird versehentlich geschlossen | Timer läuft weiter, ist nach erneutem Öffnen da |
| F5 / Seite neu geladen | Timer läuft weiter |
| Tablet-Akku leer, Neustart | Timer läuft weiter |
| Kollege schaut am zweiten Gerät nach | sieht dieselben Timer live |
| Dienst wird neu gestartet (Update) | Timer läuft weiter, **Anmeldung bleibt auch bestehen** |
| Browser über Nacht zu | Zeit läuft weiter wie eine Wanduhr |
**Der letzte Punkt ist eine bewusste fachliche Entscheidung:** Ein laufender
Timer zählt die reale verstrichene Zeit, weil der Kunde ja tatsächlich weiter
im Laden sitzt. Wer die Zeit anhalten will, drückt „Pause". Falls das für den
Firmeneinsatz anders gewünscht ist, siehe Abschnitt 9.
Der Browser zählt zwischen zwei Server-Abgleichen nur lokal hoch, damit die
Anzeige flüssig läuft (alle 250 ms). Abgeglichen wird alle 15 Sekunden und
zusätzlich sofort, wenn der Tab wieder sichtbar wird. Beim Rechnen wird bewusst
nur mit *Zeitdifferenzen* gearbeitet, nie mit absoluten Zeitstempeln — so ist
eine falsch gestellte Uhr auf dem Tablet unerheblich.
### Der Betrag entsteht auf dem Server
Der Browser berechnet den Betrag nur für die Anzeige. Was in die Historie
geschrieben wird, rechnet ausschließlich der Server (`POST /api/timers/:id/stop`).
Ein manipulierter Client kann den gespeicherten Betrag nicht beeinflussen.
### Eigener Sitzungsspeicher
`express-session` legt Sitzungen standardmäßig im Arbeitsspeicher ab — nach jedem
Neustart müssten sich alle Geräte neu anmelden. Da die Timer den Neustart
überleben, wäre das ein Bruch im Ablauf. Deshalb steckt in `server.js` eine
kleine `SqliteSessionStore`-Klasse (~50 Zeilen), die Sitzungen in derselben
SQLite-Datei ablegt. **Bewusst ohne Zusatzpaket**, um die Abhängigkeitsliste
kurz zu halten.
---
## 3. Die drei Stolpersteine — bitte zuerst lesen
### 3.1 `COOKIE_SECURE` (scheitert stumm!)
In der `.env` steht standardmäßig `COOKIE_SECURE=true`. Das heißt: Der Browser
nimmt das Sitzungs-Cookie **nur über HTTPS** an.
**Symptom bei falscher Einstellung:** Das Passwort wird akzeptiert (die API
antwortet mit `200`), aber der Nutzer landet sofort wieder auf der Login-Maske.
Endlosschleife, keine Fehlermeldung, nichts im Log.
| Deployment | Einstellung |
|---|---|
| Über Reverse Proxy mit HTTPS (`https://timer.firma.de`) | `COOKIE_SECURE=true` |
| Intern ohne TLS (`http://192.168.1.50:3003`) | `COOKIE_SECURE=false` |
| Über `http://localhost` zum Testen | `COOKIE_SECURE=false` |
### 3.2 `HOST` — Bindeadresse
Standard ist `HOST=127.0.0.1`. Die App ist dann **nur lokal** erreichbar, was
richtig ist, wenn ein Reverse Proxy davorsteht.
Ohne Reverse Proxy (App direkt im LAN): `HOST=0.0.0.0` setzen, sonst kommt kein
anderes Gerät heran. Dann aber unbedingt auch `COOKIE_SECURE=false` (siehe oben)
und `TRUST_PROXY=false`.
### 3.3 `node_modules` nicht kopieren
Das Paket `sqlite3` enthält kompilierte native Binärdateien. Ein aus dieser ZIP
oder von einem anderen Rechner kopiertes `node_modules` führt zu
`Error: invalid ELF header` oder `Module did not self-register`.
**Immer** auf dem Zielsystem installieren:
```bash
cd backend
npm ci # bevorzugt, nutzt package-lock.json
# oder: npm install
```
Falls `npm ci` mangels Compiler scheitert (`node-gyp`-Fehler), fehlen die
Build-Werkzeuge:
```bash
# Debian/Ubuntu
apt-get install -y python3 make g++
# RHEL/Alma
dnf install -y python3 make gcc-c++
```
---
## 4. Installation Schritt für Schritt
```bash
# 1. Entpacken
unzip timer-app.zip -d /opt/
cd /opt/timer-app
# 2. Abhängigkeiten (auf dem Zielsystem bauen!)
cd backend
npm ci
# 3. Konfiguration anlegen
cp .env.example .env
# 4. Zufallswert für SESSION_SECRET erzeugen
npm run gen-secret
# -> Ausgabe in die .env bei SESSION_SECRET eintragen
# 5. Passwort-Hash erzeugen
npm run hash-password -- "EuerPasswort"
# -> Ausgabe in die .env bei PASSWORD_HASH eintragen
# 6. Datenverzeichnis sicherstellen
mkdir -p ../data
# 7. Testlauf im Vordergrund
node server.js
# Erwartete Ausgabe:
# Timer App laeuft auf http://127.0.0.1:3003
# Datenbank verbunden: .../data/timer.db
# Standardtarif gesetzt: 10 EUR pro 10 Minuten
# Datenbank bereit
# 8. Prüfen
curl http://127.0.0.1:3003/health
```
Die App **bricht beim Start bewusst ab**, wenn `SESSION_SECRET` oder das
Passwort fehlen — lieber laut scheitern als unsicher laufen.
---
## 5. Konfiguration (`.env`)
| Variable | Standard | Bedeutung |
|---|---|---|
| `PORT` | `3003` | Lauschender Port |
| `HOST` | `127.0.0.1` | Bindeadresse — siehe 3.2 |
| `TRUST_PROXY` | `1` | Anzahl Proxy-Ebenen davor; ohne Proxy `false` |
| `COOKIE_SECURE` | `true` | Cookie nur über HTTPS — siehe 3.1 |
| `SESSION_SECRET` | — | **Pflicht.** Zufallswert, `npm run gen-secret` |
| `PASSWORD_HASH` | — | bcrypt-Hash, `npm run hash-password -- "…"` |
| `PASSWORD` | — | Notfall-Alternative im Klartext; App warnt beim Start |
| `LOGIN_MAX_ATTEMPTS` | `20` | Anmeldeversuche pro Zeitfenster |
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei |
| `RATE_PER_10MIN` | `10` | **Nur beim allerersten Start.** Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt. |
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
Befehlssubstitution war beim Anlegen der Datei nie ausgeführt worden. Mit einem
erratbaren Secret lassen sich Sitzungs-Cookies fälschen und die Anmeldung
umgehen. Deshalb: Wert immer mit `npm run gen-secret` erzeugen, nie von Hand
ausdenken, nie aus einer Doku kopieren.
---
## 6. Betrieb
### systemd-Unit
Vorlage liegt bei unter `deploy/timer-app.service`. Anpassen: `WorkingDirectory`,
`User`, ggf. `ExecStart`-Pfad zu node.
```bash
cp deploy/timer-app.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now timer-app
systemctl status timer-app
journalctl -u timer-app -f
```
Die Unit läuft in der Vorlage als eigener Benutzer (nicht root) und enthält
die üblichen Härtungsoptionen. Wenn ein eigener Benutzer angelegt wird, muss
ihm das `data/`-Verzeichnis gehören:
```bash
useradd --system --no-create-home --shell /usr/sbin/nologin timerapp
chown -R timerapp:timerapp /opt/timer-app/data
chown timerapp:timerapp /opt/timer-app/backend/.env
chmod 600 /opt/timer-app/backend/.env
```
### Reverse Proxy
Beispiele liegen bei: `deploy/Caddyfile.example` und `deploy/nginx.conf.example`.
Wichtig in beiden Fällen:
* `X-Forwarded-Proto` durchreichen (sonst greift `trust proxy` nicht richtig)
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
* Websockets werden **nicht** gebraucht
### Backup
Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
```bash
# konsistente Sicherung im laufenden Betrieb (WAL-Modus ist aktiv!)
sqlite3 /opt/timer-app/data/timer.db ".backup '/backup/timer-$(date +%F).db'"
```
⚠️ **Nicht** einfach `cp` auf eine laufende Datenbank — im WAL-Modus kann eine
so erzeugte Kopie unvollständig sein. Entweder `.backup` benutzen (wie oben)
oder den Dienst vorher stoppen.
Die `.env` gehört ebenfalls ins Backup (enthält Secret und Passwort-Hash),
aber getrennt und mit engeren Rechten.
---
## 7. Aufbau des Codes
```
timer-app/
├── DEPLOY.md ← dieses Dokument
├── README.md ← Kurzanleitung für Anwender
├── backend/
│ ├── server.js ← die gesamte Server-Logik (~470 Zeilen)
│ ├── package.json
│ ├── package-lock.json
│ ├── .env.example
│ └── tools/hash-password.js ← Hilfsskript für den Passwort-Hash
├── frontend/
│ ├── index.html ← Markup + komplettes CSS
│ ├── app.js ← Oberflächenlogik
│ ├── logo.png ← ⚠️ Firmenlogo, ggf. austauschen
│ └── favicon.jpg
└── deploy/
├── timer-app.service
├── Caddyfile.example
└── nginx.conf.example
```
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
Tarif & Berechnung, Sitzungsspeicher, Auth, Timer, Einstellungen, Historie).
### Datenmodell
```sql
-- Laufende Timer: eine Zeile pro Platz/Kunde
active_timers(
id, customer_name, started_at, accumulated_ms, is_running, created_at
)
-- started_at: ms-Zeitstempel des letzten Starts/Fortsetzens (NULL bei Pause)
-- accumulated_ms: vor der aktuellen Laufphase gesammelte Zeit
-- Vergangene Zeit = accumulated_ms + (jetzt - started_at), falls is_running
-- Abgeschlossene Vorgänge
sessions(
id, session_id, start_time, end_time, duration, amount,
customer_name, rate_amount, rate_interval, created_at
)
-- rate_amount/rate_interval halten fest, welcher Tarif beim Beenden galt,
-- damit eine spätere Tarifänderung alte Belege nicht rückwirkend verändert.
-- created_at ist in SEKUNDEN, alle anderen Zeiten in MILLISEKUNDEN (historisch).
settings(key, value) -- rate_amount, rate_interval
user_sessions(sid, expires, data) -- Anmelde-Sitzungen
```
Das Schema wird beim Start **automatisch migriert** (`ALTER TABLE ADD COLUMN`,
nur falls Spalte fehlt). Eine Datenbank der Vorgängerversion kann direkt
weiterverwendet werden; getestet mit 21 Altdatensätzen, die unverändert erhalten
blieben und in der Oberfläche als „ohne Namen" erscheinen.
### Die Abrechnungsregel ändern
Genau eine Funktion in `server.js`, plus die gleichnamige in `app.js` für
die Anzeige:
```js
function calcAmount(ms, rateAmount, rateInterval) {
if (!ms || ms <= 0) return 0;
const minutes = Math.ceil(ms / 60000); // angefangene Minute
const units = Math.ceil(minutes / rateInterval); // angefangene Einheit
return Math.round(units * rateAmount * 100) / 100;
}
```
Für minutengenaue Abrechnung z. B.:
`return Math.round((ms / 60000) * (rateAmount / rateInterval) * 100) / 100;`
---
## 8. API-Referenz
Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
`/api/logout` erfordern eine gültige Sitzung (sonst `401`).
| Methode | Pfad | Zweck |
|---|---|---|
| `POST` | `/api/login` | `{password}` → Sitzung |
| `GET` | `/api/auth/status` | `{authenticated: bool}` |
| `POST` | `/api/logout` | Sitzung beenden |
| `GET` | `/api/timers` | alle laufenden Timer + aktueller Tarif |
| `POST` | `/api/timers` | `{customerName}` → neuer Timer |
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
| `POST` | `/api/timers/:id/pause` | anhalten |
| `POST` | `/api/timers/:id/resume` | fortsetzen |
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern |
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
| `GET` | `/api/settings` | Tarif lesen |
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` |
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
| `GET` | `/health` | Status, ohne Anmeldung |
`from`/`to` sind Datumsangaben `YYYY-MM-DD` und werden in der **Zeitzone des
Servers** ausgewertet. Wenn Server und Laden in verschiedenen Zeitzonen stehen,
`TZ=Europe/Berlin` in der systemd-Unit setzen.
Die CSV ist für deutsches Excel vorbereitet: Semikolon als Trenner, UTF-8-BOM
(damit Umlaute stimmen), Komma als Dezimaltrennzeichen.
---
## 9. Bekannte Grenzen — bewusst so gelassen
Diese Punkte sind **keine Fehler**, sondern Entscheidungen. Sie stehen hier,
damit sie bei Bedarf eingeordnet werden können.
| Thema | Aktueller Stand | Wenn die Firma mehr braucht |
|---|---|---|
| **Ein gemeinsames Passwort** | Alle teilen sich ein Passwort, keine Benutzerkonten, keine 2FA | Für „wer hat welchen Vorgang erfasst" wären echte Konten nötig. Der Sitzungsspeicher liegt bereits in der DB, eine `users`-Tabelle wäre der nächste Schritt. |
| **Jeder sieht alles** | Wer angemeldet ist, sieht alle Timer und die gesamte Historie | Im Ladenbetrieb gewollt (Kollege übernimmt die Schicht). Mandantentrennung wäre ein größerer Umbau. |
| **Kein Audit-Log** | Löschen eines Historien-Eintrags ist endgültig und wird nicht protokolliert | Falls die Erfassung abrechnungsrelevant wird, ist ein `deleted_at` statt echtem Löschen sinnvoll (GoBD-Thema — bitte fachlich klären). |
| **Wanduhr-Verhalten** | Ein laufender Timer zählt auch nachts weiter | Wenn stattdessen automatisch pausiert werden soll: eine Obergrenze in `elapsedMs()` einbauen oder einen nächtlichen Auto-Pause-Job. |
| **Keine Mehrprozess-Fähigkeit** | Ein Node-Prozess, SQLite | Für die erwartete Last (ein Laden, wenige Geräte) um Größenordnungen ausreichend. Bei mehreren Filialen: PostgreSQL und echte Benutzerverwaltung. |
| **Logo** | `frontend/logo.png` ist das Logo des bisherigen Einsatzorts, 654 KB | Austauschen und dabei gern verkleinern (~50 KB reichen). Ebenso `favicon.jpg`. Der Seitentitel steht in `index.html` (`<title>`). |
| **Zeitzone** | Server-Zeitzone bestimmt Tagesgrenzen im Filter | `TZ` in der Unit setzen |
---
## 10. Fehlersuche
| Symptom | Ursache | Lösung |
|---|---|---|
| Login akzeptiert, springt zurück zur Maske | `COOKIE_SECURE=true` ohne HTTPS | `COOKIE_SECURE=false` oder TLS davor |
| Von anderen Geräten nicht erreichbar | `HOST=127.0.0.1` ohne Proxy | `HOST=0.0.0.0` |
| `Error: invalid ELF header` | fremdes `node_modules` | löschen, `npm ci` |
| `node-gyp`-Fehler bei `npm ci` | Build-Werkzeuge fehlen | `apt install python3 make g++` |
| `FATAL: SESSION_SECRET ist nicht gesetzt` | `.env` fehlt/unvollständig | Abschnitt 4, Schritt 35 |
| `SQLITE_CANTOPEN` | `data/` fehlt oder Rechte | `mkdir -p data && chown` |
| Zeiten wirken um Stunden verschoben | Server-Zeitzone | `TZ=Europe/Berlin` in der Unit |
| Alle ausgeloggt nach Neustart | sollte **nicht** passieren | prüfen, ob `user_sessions` in der DB existiert |
Diagnose-Einstieg:
```bash
systemctl status timer-app
journalctl -u timer-app -n 50 --no-pager
curl -s http://127.0.0.1:3003/health
sqlite3 data/timer.db "select count(*) from sessions; select * from active_timers;"
```
---
## 11. Sicherheit — Stand und Empfehlungen
Vorhanden:
* Passwort als bcrypt-Hash (Kostenfaktor 12), nicht im Klartext im Code
* Sitzungs-ID wird beim Login neu vergeben (kein Session-Fixation)
* Cookie `httpOnly`, `sameSite=lax`, optional `secure`
* Ratenbegrenzung auf dem Login (20 Versuche / 15 Min, konfigurierbar)
* Alle SQL-Zugriffe mit gebundenen Parametern (keine String-Verkettung)
* Ausgabe im Frontend wird escaped (kein `innerHTML` mit Rohdaten aus der DB)
* Beträge werden serverseitig berechnet
Vor dem Produktivgang zu klären:
1. **Erreichbarkeit.** Muss die App aus dem Internet erreichbar sein oder reicht
das Firmen-LAN/VPN? Ein gemeinsames Passwort ohne 2FA gehört nach Möglichkeit
nicht offen ins Internet.
2. **Passwort wechseln.** Der Hash in dieser ZIP ist **nicht** gesetzt —
`.env.example` enthält keinen. Neu erzeugen (Abschnitt 4).
3. **TLS.** Wenn öffentlich erreichbar: Reverse Proxy mit Zertifikat und
`COOKIE_SECURE=true`.
4. **Personenbezug.** Sobald echte Kundennamen eingetippt werden, sind das
personenbezogene Daten — Aufbewahrungsdauer und Löschkonzept mit der
zuständigen Stelle klären. Ein Löschbefehl für alte Einträge:
```sql
DELETE FROM sessions WHERE created_at < strftime('%s','now','-90 days');
```
---
## 12. Was beim letzten Umbau geändert wurde (03.08.2026)
Für den Fall, dass jemand die Vorgängerversion kennt:
* Timer laufen jetzt serverseitig statt im Browser — überleben Reload, Neustart
und Gerätewechsel *(vorher ging bei F5 die gesamte Zeit verloren)*
* Mehrere Timer gleichzeitig, jeder mit eigenem Namen *(vorher genau einer)*
* Tarif serverseitig und geräteübergreifend *(vorher `localStorage`, pro Browser
unterschiedlich, und der Betrag kam vom Client)*
* Historie mit Datums- und Namensfilter, Seitenweise-Nachladen, Löschen-Knopf,
CSV-Export *(vorher: fest 100 Einträge, kein Filter, Löschen nur über die DB)*
* Anmeldung mit bcrypt statt Klartextvergleich; kein fest eingebautes
Ersatzpasswort mehr im Quelltext
* Sitzungen überleben Neustarts
* `HOST`, `COOKIE_SECURE`, `TRUST_PROXY`, `DB_PATH` konfigurierbar
* Kein Google-Fonts-Abruf mehr — die Schrift kam vorher bei jedem Seitenaufruf
von einem externen Server, was in Firmennetzen blockiert sein kann und
datenschutzrechtlich unnötig Angriffsfläche bietet. Jetzt System-Schriften.
* Sauberes Herunterfahren bei SIGTERM, WAL-Modus, Index auf `created_at`
Getestet: 36 automatisierte Prüfungen (Anmeldung, Parallelbetrieb, Pause/Weiter,
Neustart-Persistenz mit echtem Prozesswechsel, Speichern/Verwerfen, Tarifwechsel,
CSV, Filter, Löschen, Abmelden) sowie ein Durchlauf im echten Browser.
---
## 13. Wenn etwas unklar ist
Die App ist klein genug, um sie vollständig zu lesen: `server.js` ~470 Zeilen,
`app.js` ~460 Zeilen, beide durchgehend kommentiert und auf Deutsch beschriftet.
Im Zweifel ist das Lesen der Quelle schneller als das Raten.
+73
View File
@@ -0,0 +1,73 @@
# Timer — Zeiterfassung
Zeiterfassung mit automatischer Kostenberechnung für den Einsatz im Laden.
> **Installation und Technik:** siehe [DEPLOY.md](DEPLOY.md).
> Diese Datei ist die Kurzanleitung für die tägliche Bedienung.
---
## Anmelden
Seite im Browser öffnen, Passwort eingeben. Die Anmeldung gilt 24 Stunden und
verlängert sich bei Benutzung — ein Tablet am Tresen muss also nicht täglich neu
angemeldet werden.
## Timer starten
Oben optional einen Namen eintippen — zum Beispiel „Tisch 2", „Herr Müller" oder
„Reparatur Werkbank" — und auf **▶ Neuer Timer** klicken.
Es können **beliebig viele Timer gleichzeitig** laufen, einer je Platz oder Kunde.
Jeder bekommt eine eigene Karte mit Zeit und laufendem Betrag. Der Name lässt
sich jederzeit nachträglich ändern, einfach im Feld überschreiben.
## Während der Timer läuft
| Knopf | Wirkung |
|---|---|
| **❚❚ Pause** | Zeit anhalten, Betrag friert ein |
| **▶ Weiter** | pausierten Timer fortsetzen |
| **✓ Beenden** | Vorgang abschließen und in der Historie speichern |
| **✕ Verwerfen** | Timer wegwerfen, **nichts** wird gespeichert |
Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
### Was passiert, wenn …
* **… der Browser geschlossen wird?** Nichts. Der Timer läuft auf dem Server
weiter und ist beim nächsten Öffnen wieder da.
* **… das Tablet neu startet?** Ebenfalls nichts — einfach wieder aufrufen.
* **… ein Kollege am zweiten Gerät nachschaut?** Er sieht dieselben Timer live.
* **… über Nacht vergessen wurde zu stoppen?** Der Timer zählt weiter, wie eine
Wanduhr. Die Karte zeigt, seit wann er läuft. Wer die Zeit korrigieren will,
verwirft den Timer und erfasst ihn neu.
## Historie
Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name,
Dauer und Betrag. Oben summieren drei Kacheln den gewählten Zeitraum.
* Filter nach **Von/Bis-Datum** und nach **Name**
* Schnellauswahl **Heute** und **Dieser Monat**
* **⬇ CSV-Export** lädt genau die gefilterte Auswahl herunter — die Datei lässt
sich direkt in Excel öffnen, Umlaute und Beträge stimmen
* Das **🗑**-Symbol löscht einen Eintrag endgültig (mit Rückfrage)
## Einstellungen
Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall,
zum Beispiel „10 € pro 10 Minuten". Die Vorschau rechnet Beispiele durch.
Wichtig zu wissen:
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
kosten bei einem 10-Minuten-Takt bereits zwei Einheiten.
* Der Tarif gilt für **alle Geräte gemeinsam**.
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
## Abmelden
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon
unberührt und laufen weiter.
+57
View File
@@ -0,0 +1,57 @@
# ===========================================================================
# Timer App - Konfiguration
# Diese Datei nach ".env" kopieren und ausfuellen. Die .env gehoert NICHT
# in die Versionsverwaltung.
# ===========================================================================
# --- Netzwerk --------------------------------------------------------------
PORT=3003
# Auf welcher Adresse gelauscht wird.
# 127.0.0.1 = nur lokal erreichbar -> RICHTIG, wenn ein Reverse Proxy
# (nginx, Caddy, Traefik) davorsteht
# 0.0.0.0 = im ganzen Netz erreichbar -> nur ohne Reverse Proxy, und dann
# bitte COOKIE_SECURE=false beachten (siehe unten)
HOST=127.0.0.1
# Anzahl der Proxy-Ebenen davor. Bei genau einem Reverse Proxy: 1.
# Ohne Reverse Proxy: false
TRUST_PROXY=1
# --- Sicherheit ------------------------------------------------------------
# ACHTUNG, haeufigste Fehlerquelle beim Deployment:
# true -> Cookie wird NUR ueber HTTPS gesetzt. Laeuft die App ueber http://,
# scheitert der Login STUMM (Passwort wird akzeptiert, aber die
# Sitzung kommt nie zustande).
# false -> auch ueber http:// nutzbar. Nur im internen Netz vertretbar.
COOKIE_SECURE=true
# Zufaelliger Wert, unterschreibt die Sitzungs-Cookies.
# Erzeugen mit: npm run gen-secret
SESSION_SECRET=HIER_EINEN_ZUFALLSWERT_EINSETZEN
# Passwort fuer die Anmeldung. ZWEI Wege - bitte den ersten waehlen:
#
# 1) EMPFOHLEN: bcrypt-Hash. Erzeugen mit:
# npm run hash-password -- "MeinPasswort"
PASSWORD_HASH=
# 2) Notfalls Klartext. Wird beim Start gehasht, steht aber lesbar in dieser
# Datei. Der Server warnt beim Start. Nur eins von beiden setzen.
#PASSWORD=
# Anmeldeversuche pro Zeitfenster (Schutz gegen Durchprobieren)
LOGIN_MAX_ATTEMPTS=20
LOGIN_WINDOW_MIN=15
# --- Daten -----------------------------------------------------------------
# Pfad zur SQLite-Datei. Standard: ../data/timer.db relativ zum backend/.
# Das Verzeichnis muss existieren und fuer den Dienst-Benutzer schreibbar sein.
#DB_PATH=/var/lib/timer-app/timer.db
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
RATE_PER_10MIN=10
+107
View File
@@ -0,0 +1,107 @@
// ---------------------------------------------------------------------------
// Authentik-Anbindung (OpenID Connect) — gleiches Muster wie das VVL-Tool.
//
// openid-client v6 ist ESM-only, diese App ist CommonJS. Deshalb wird das
// Modul einmalig per dynamischem import() geladen und zwischengespeichert.
// Ist OIDC nicht konfiguriert (Variablen fehlen), bleibt der Passwort-Login
// aktiv und diese Funktionen melden "nicht bereit".
// ---------------------------------------------------------------------------
const ISSUER = process.env.OIDC_ISSUER || '';
const CLIENT_ID = process.env.OIDC_CLIENT_ID || '';
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '';
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || '';
const ALLOW_INSECURE = String(process.env.OIDC_ALLOW_INSECURE || '') === 'true';
const SCOPE = 'openid profile email';
let oidc = null; // das openid-client-Modul (via dynamic import)
let oidcConfig = null; // Ergebnis der Discovery
function isConfigured() {
return Boolean(ISSUER && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
}
function isReady() {
return oidcConfig !== null;
}
/** Discovery beim Serverstart. Wirft nicht — der Server startet auch, wenn
* Authentik gerade nicht erreichbar ist (Login schlaegt dann fehl). */
async function init() {
if (!isConfigured()) {
console.warn('[oidc] Authentik nicht konfiguriert (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI) — nur Passwort-Login aktiv.');
return;
}
try {
oidc = await import('openid-client');
oidcConfig = await oidc.discovery(
new URL(ISSUER),
CLIENT_ID,
CLIENT_SECRET,
undefined,
ALLOW_INSECURE ? { execute: [oidc.allowInsecureRequests] } : undefined
);
console.log('[oidc] Discovery erfolgreich:', ISSUER);
} catch (err) {
console.error('[oidc] Discovery fehlgeschlagen:', err.message || err);
}
}
/** Baut die Weiterleitungs-URL zu Authentik (PKCE). */
async function buildLoginUrl() {
if (!oidcConfig) throw new Error('OIDC nicht initialisiert');
const codeVerifier = oidc.randomPKCECodeVerifier();
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConfig, {
redirect_uri: REDIRECT_URI,
scope: SCOPE,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return { url: url.href, codeVerifier, state };
}
/** Tauscht den Code gegen Tokens und liefert die Nutzer-Claims. */
async function handleCallback(currentUrl, codeVerifier, state) {
if (!oidcConfig) throw new Error('OIDC nicht initialisiert');
const tokens = await oidc.authorizationCodeGrant(oidcConfig, currentUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
});
const claims = tokens.claims();
if (!claims || !claims.sub) throw new Error('ID-Token ohne sub-Claim');
return {
user: {
sub: claims.sub,
username: claims.preferred_username || claims.sub,
displayName: claims.name || claims.preferred_username || null,
email: claims.email || null,
},
idToken: tokens.id_token || '',
};
}
/** RP-Initiated Logout: beendet auch die Authentik-Sitzung. Liefert null,
* wenn kein end_session_endpoint vorhanden oder OIDC nicht bereit ist. */
function buildLogoutUrl(idToken) {
if (!oidcConfig) return null;
try {
const params = { post_logout_redirect_uri: new URL(REDIRECT_URI).origin + '/' };
if (idToken) params.id_token_hint = idToken;
return oidc.buildEndSessionUrl(oidcConfig, params).href;
} catch {
return null;
}
}
module.exports = {
init,
isReady,
isConfigured,
buildLoginUrl,
handleCallback,
buildLogoutUrl,
REDIRECT_URI,
};
+2352
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "timer-app-backend",
"version": "2.0.0",
"description": "Zeiterfassung mit Kostenberechnung - Backend",
"main": "server.js",
"private": true,
"scripts": {
"start": "node server.js",
"hash-password": "node tools/hash-password.js",
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('hex'))\""
},
"engines": {
"node": ">=18"
},
"dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"openid-client": "^6.8.4",
"sqlite3": "^5.1.6"
}
}
+616
View File
@@ -0,0 +1,616 @@
/**
* Timer App - Backend
*
* Zeiterfassung mit Kostenberechnung fuer Ladengeschaefte.
*
* Kernprinzip: Timer leben SERVERSEITIG in der Datenbank, nicht im Browser.
* Dadurch ueberleben sie Reload, Browserwechsel, Tablet-Neustart und sind
* auf mehreren Geraeten gleichzeitig sichtbar.
*/
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcrypt');
const path = require('path');
require('dotenv').config();
const oidc = require('./oidc');
const app = express();
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const PORT = parseInt(process.env.PORT) || 3003;
const HOST = process.env.HOST || '127.0.0.1';
const TRUST_PROXY = process.env.TRUST_PROXY || '1';
// COOKIE_SECURE=true verlangt HTTPS. Ohne HTTPS wird dann KEIN Cookie gesetzt
// und der Login scheitert stumm -> siehe DEPLOY.md, Abschnitt "Stolpersteine".
const COOKIE_SECURE = String(process.env.COOKIE_SECURE || 'true') === 'true';
const SESSION_SECRET = process.env.SESSION_SECRET;
const PASSWORD_HASH = process.env.PASSWORD_HASH;
const PASSWORD_PLAIN = process.env.PASSWORD;
const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 20;
const LOGIN_WINDOW_MIN = parseInt(process.env.LOGIN_WINDOW_MIN) || 15;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/timer.db');
// --- Startpruefungen: lieber sofort und laut scheitern als still falsch laufen
if (!SESSION_SECRET) {
console.error('FATAL: SESSION_SECRET ist nicht gesetzt. Siehe .env.example.');
process.exit(1);
}
// Login ist ueber Authentik (OIDC) ODER Passwort moeglich. Nur wenn KEINES
// von beiden konfiguriert ist, gibt es keinen Anmeldeweg -> laut abbrechen.
if (!oidc.isConfigured() && !PASSWORD_HASH && !PASSWORD_PLAIN) {
console.error('FATAL: Kein Anmeldeweg konfiguriert — weder Authentik (OIDC_*) noch PASSWORD_HASH/PASSWORD.');
process.exit(1);
}
// Passwort-Login ist entfernt; passwordHash wird nur noch definiert, falls in der
// .env (Altbestand) ein Wert steht — schadet nicht, wird aber nicht mehr genutzt.
let passwordHash = PASSWORD_HASH;
if (!passwordHash && PASSWORD_PLAIN) {
passwordHash = bcrypt.hashSync(PASSWORD_PLAIN, 12);
}
app.set('trust proxy', TRUST_PROXY === 'false' ? false : (isNaN(parseInt(TRUST_PROXY)) ? TRUST_PROXY : parseInt(TRUST_PROXY)));
// ---------------------------------------------------------------------------
// Datenbank
// ---------------------------------------------------------------------------
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('FATAL: Datenbank nicht oeffenbar:', DB_PATH, err.message);
process.exit(1);
}
console.log('Datenbank verbunden:', DB_PATH);
initDatabase();
});
const dbRun = (sql, params = []) => new Promise((resolve, reject) => {
db.run(sql, params, function (err) { err ? reject(err) : resolve(this); });
});
const dbGet = (sql, params = []) => new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => err ? reject(err) : resolve(row));
});
const dbAll = (sql, params = []) => new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows));
});
async function initDatabase() {
try {
await dbRun('PRAGMA journal_mode = WAL');
await dbRun(`CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER,
duration INTEGER,
amount REAL,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`);
// Laufende Timer. Ein Datensatz pro Platz/Kunde.
await dbRun(`CREATE TABLE IF NOT EXISTS active_timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_name TEXT NOT NULL DEFAULT '',
started_at INTEGER,
accumulated_ms INTEGER NOT NULL DEFAULT 0,
is_running INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
)`);
await dbRun(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`);
// Migration: Spalten fuer Kundenname und geltenden Tarif nachruesten.
const cols = await dbAll(`PRAGMA table_info(sessions)`);
const names = cols.map(c => c.name);
if (!names.includes('customer_name')) {
await dbRun(`ALTER TABLE sessions ADD COLUMN customer_name TEXT DEFAULT ''`);
console.log('Migration: sessions.customer_name ergaenzt');
}
if (!names.includes('rate_amount')) {
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_amount REAL`);
console.log('Migration: sessions.rate_amount ergaenzt');
}
if (!names.includes('rate_interval')) {
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_interval INTEGER`);
console.log('Migration: sessions.rate_interval ergaenzt');
}
await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)`);
// Standardtarif einmalig setzen (aus .env uebernommen, falls vorhanden)
const existing = await dbGet(`SELECT value FROM settings WHERE key = 'rate_amount'`);
if (!existing) {
const envAmount = parseInt(process.env.RATE_PER_10MIN) || 10;
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_amount', ?)`, [String(envAmount)]);
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_interval', '10')`);
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
}
console.log('Datenbank bereit');
} catch (err) {
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Tarif & Berechnung (bewusst NUR serverseitig - der Client rechnet nur fuer
// die Anzeige mit, der verbindliche Betrag entsteht hier)
// ---------------------------------------------------------------------------
async function getRate() {
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
return {
rateAmount: parseFloat(map.rate_amount) || 10,
rateInterval: parseInt(map.rate_interval) || 10
};
}
/** Jede angefangene Einheit wird voll berechnet. */
function calcAmount(ms, rateAmount, rateInterval) {
if (!ms || ms <= 0) return 0;
const minutes = Math.ceil(ms / 60000);
const units = Math.ceil(minutes / rateInterval);
return Math.round(units * rateAmount * 100) / 100;
}
/** Vergangene Zeit eines Timers - laufend oder pausiert. */
function elapsedMs(timer) {
const base = timer.accumulated_ms || 0;
if (!timer.is_running || !timer.started_at) return base;
return base + (Date.now() - timer.started_at);
}
async function decorate(timer, rate) {
const ms = elapsedMs(timer);
return {
id: timer.id,
customerName: timer.customer_name || '',
isRunning: !!timer.is_running,
elapsedMs: ms,
amount: calcAmount(ms, rate.rateAmount, rate.rateInterval),
startedAt: timer.started_at,
createdAt: timer.created_at,
serverNow: Date.now()
};
}
// ---------------------------------------------------------------------------
// Sitzungsspeicher
//
// Bewusst selbst gebaut statt per Zusatzpaket: der Standardspeicher von
// express-session liegt im Arbeitsspeicher: nach jedem Neustart des Dienstes
// muessten sich alle Geraete neu anmelden. Da die Timer den Neustart
// ueberleben, waere das ein Bruch im Bedienablauf.
// ---------------------------------------------------------------------------
class SqliteSessionStore extends session.Store {
constructor() {
super();
db.run(`CREATE TABLE IF NOT EXISTS user_sessions (
sid TEXT PRIMARY KEY,
expires INTEGER NOT NULL,
data TEXT NOT NULL
)`, () => {
db.run(`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires)`);
});
// Abgelaufene Sitzungen stuendlich entfernen
this.cleanupTimer = setInterval(() => {
db.run(`DELETE FROM user_sessions WHERE expires < ?`, [Date.now()]);
}, 60 * 60 * 1000);
this.cleanupTimer.unref();
}
_expiry(sess) {
const ms = sess && sess.cookie && sess.cookie.maxAge;
return Date.now() + (typeof ms === 'number' ? ms : 24 * 60 * 60 * 1000);
}
get(sid, cb) {
db.get(`SELECT data, expires FROM user_sessions WHERE sid = ?`, [sid], (err, row) => {
if (err) return cb(err);
if (!row) return cb(null, null);
if (row.expires < Date.now()) {
return db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], () => cb(null, null));
}
try {
cb(null, JSON.parse(row.data));
} catch (e) {
cb(null, null);
}
});
}
set(sid, sess, cb = () => {}) {
let data;
try {
data = JSON.stringify(sess);
} catch (e) {
return cb(e);
}
db.run(`INSERT INTO user_sessions (sid, expires, data) VALUES (?, ?, ?)
ON CONFLICT(sid) DO UPDATE SET expires = excluded.expires, data = excluded.data`,
[sid, this._expiry(sess), data], cb);
}
touch(sid, sess, cb = () => {}) {
db.run(`UPDATE user_sessions SET expires = ? WHERE sid = ?`, [this._expiry(sess), sid], cb);
}
destroy(sid, cb = () => {}) {
db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], cb);
}
}
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
store: new SqliteSessionStore(),
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true, // aktive Nutzung verlaengert die Anmeldung
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
sameSite: 'lax',
secure: COOKIE_SECURE
}
}));
app.use(express.static(path.join(__dirname, '../frontend')));
function requireAuth(req, res, next) {
if (req.session.authenticated) return next();
res.status(401).json({ error: 'Nicht angemeldet' });
}
// Fehler aus async-Handlern sauber weiterreichen
const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
// Anmeldung ausschliesslich ueber Authentik (SSO). Der fruehere Passwort-Login
// (POST /api/login) wurde bewusst entfernt.
app.get('/api/auth/status', (req, res) => {
res.json({
authenticated: !!req.session.authenticated,
user: req.session.user || null, // bei Authentik-Login gesetzt, bei Passwort-Login null
oidcConfigured: oidc.isReady() // steuert den "Anmelden mit Authentik"-Button
});
});
// --- Authentik (OpenID Connect) -------------------------------------------
// Fuers Frontend: ist der Authentik-Login verfuegbar?
app.get('/auth/config', (req, res) => {
res.json({ oidcConfigured: oidc.isReady() });
});
// Start des Authentik-Logins -> Weiterleitung zu Authentik
app.get('/auth/login', wrap(async (req, res) => {
if (!oidc.isReady()) {
return res.status(503).send('Authentik ist nicht konfiguriert.');
}
const { url, codeVerifier, state } = await oidc.buildLoginUrl();
req.session.oidcVerifier = codeVerifier;
req.session.oidcState = state;
req.session.save(err => {
if (err) return res.status(500).send('Session-Fehler');
res.redirect(url);
});
}));
// Rueckkehr von Authentik (Code -> Tokens -> Sitzung)
app.get('/auth/callback', wrap(async (req, res) => {
const { oidcVerifier, oidcState } = req.session;
if (!oidcVerifier || !oidcState) {
return res.redirect('/?error=session');
}
try {
const currentUrl = new URL(req.originalUrl, oidc.REDIRECT_URI);
const { user, idToken } = await oidc.handleCallback(currentUrl, oidcVerifier, oidcState);
req.session.regenerate(err => {
if (err) return res.redirect('/?error=session');
req.session.authenticated = true;
req.session.userId = req.sessionID;
req.session.user = { username: user.username, displayName: user.displayName, email: user.email };
req.session.idToken = idToken; // fuer den Single-Logout
req.session.save(e => res.redirect(e ? '/?error=session' : '/'));
});
} catch (err) {
console.error('[auth] Callback-Fehler:', err.message || err);
res.redirect('/?error=oidc');
}
}));
// Logout per GET (Browser navigiert hierher): lokal beenden und falls per
// Authentik angemeldet auch die SSO-Sitzung mitbeenden (RP-Initiated Logout).
app.get('/auth/logout', (req, res) => {
if (req.get('sec-fetch-site') === 'cross-site') {
return res.status(403).send('Abmeldung nur aus der Anwendung heraus.');
}
const idToken = req.session.idToken;
const logoutUrl = idToken ? oidc.buildLogoutUrl(idToken) : null;
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.redirect(logoutUrl || '/');
});
});
// Passwort-Logout (Fallback, wird per fetch aufgerufen)
app.post('/api/logout', (req, res) => {
req.session.destroy(() => res.json({ success: true }));
});
// ---------------------------------------------------------------------------
// Laufende Timer
// ---------------------------------------------------------------------------
app.get('/api/timers', requireAuth, wrap(async (req, res) => {
const rate = await getRate();
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
res.json({ timers, rate, serverNow: Date.now() });
}));
app.post('/api/timers', requireAuth, wrap(async (req, res) => {
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
const now = Date.now();
const result = await dbRun(
`INSERT INTO active_timers (customer_name, started_at, accumulated_ms, is_running, created_at)
VALUES (?, ?, 0, 1, ?)`,
[name, now, now]
);
const rate = await getRate();
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [result.lastID]);
res.json({ timer: await decorate(row, rate) });
}));
app.post('/api/timers/:id/pause', requireAuth, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
if (!t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
await dbRun(
`UPDATE active_timers SET accumulated_ms = ?, is_running = 0, started_at = NULL WHERE id = ?`,
[elapsedMs(t), t.id]
);
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
res.json({ timer: await decorate(row, await getRate()) });
}));
app.post('/api/timers/:id/resume', requireAuth, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
if (t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
await dbRun(`UPDATE active_timers SET started_at = ?, is_running = 1 WHERE id = ?`, [Date.now(), t.id]);
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
res.json({ timer: await decorate(row, await getRate()) });
}));
app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
await dbRun(`UPDATE active_timers SET customer_name = ? WHERE id = ?`, [name, t.id]);
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
res.json({ timer: await decorate(row, await getRate()) });
}));
/** Beenden: schreibt die Session in die Historie und entfernt den Timer. */
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
const rate = await getRate();
const duration = elapsedMs(t);
const amount = calcAmount(duration, rate.rateAmount, rate.rateInterval);
const endTime = Date.now();
if (duration <= 0) {
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
return res.json({ saved: false, reason: 'Keine Zeit erfasst - nichts gespeichert.' });
}
const result = await dbRun(
`INSERT INTO sessions (session_id, start_time, end_time, duration, amount, customer_name, rate_amount, rate_interval, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[req.session.userId || 'unknown', t.created_at, endTime, duration, amount,
t.customer_name || '', rate.rateAmount, rate.rateInterval, Math.floor(endTime / 1000)]
);
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
res.json({ saved: true, id: result.lastID, duration, amount });
}));
/** Verwerfen: Timer weg, nichts in der Historie. */
app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [req.params.id]);
res.json({ success: true });
}));
// ---------------------------------------------------------------------------
// Einstellungen (serverseitig - gelten fuer ALLE Geraete)
// ---------------------------------------------------------------------------
app.get('/api/settings', requireAuth, wrap(async (req, res) => {
res.json(await getRate());
}));
app.put('/api/settings', requireAuth, wrap(async (req, res) => {
const amount = parseFloat(req.body && req.body.rateAmount);
const interval = parseInt(req.body && req.body.rateInterval);
if (!isFinite(amount) || amount <= 0 || amount > 10000) {
return res.status(400).json({ error: 'Betrag muss zwischen 0 und 10000 liegen.' });
}
if (!Number.isInteger(interval) || interval < 1 || interval > 600) {
return res.status(400).json({ error: 'Intervall muss zwischen 1 und 600 Minuten liegen.' });
}
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_amount',?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(amount)]);
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]);
res.json(await getRate());
}));
// ---------------------------------------------------------------------------
// Historie
// ---------------------------------------------------------------------------
/** Baut die WHERE-Klausel fuer den Datumsfilter (Serverzeitzone). */
function dateFilter(query) {
const clauses = [];
const params = [];
if (query.from) {
clauses.push(`date(created_at, 'unixepoch', 'localtime') >= date(?)`);
params.push(String(query.from));
}
if (query.to) {
clauses.push(`date(created_at, 'unixepoch', 'localtime') <= date(?)`);
params.push(String(query.to));
}
if (query.q) {
clauses.push(`customer_name LIKE ?`);
params.push('%' + String(query.q) + '%');
}
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
}
app.get('/api/sessions', requireAuth, wrap(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
const { where, params } = dateFilter(req.query);
const rows = await dbAll(
`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
// Summen ueber den GESAMTEN Filter, nicht nur ueber die angezeigte Seite
const totals = await dbGet(
`SELECT COUNT(*) AS count, COALESCE(SUM(duration),0) AS duration, COALESCE(SUM(amount),0) AS amount
FROM sessions ${where}`,
params
);
res.json({ sessions: rows, totals, limit, offset });
}));
app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
const result = await dbRun(`DELETE FROM sessions WHERE id = ?`, [req.params.id]);
if (result.changes === 0) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
res.json({ success: true });
}));
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
const { where, params } = dateFilter(req.query);
const rows = await dbAll(`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC`, params);
const esc = v => {
const s = String(v == null ? '' : v);
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const fmtTs = ms => {
if (!ms) return '';
const d = new Date(Number(ms));
const p = n => String(n).padStart(2, '0');
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const fmtDur = ms => {
const s = Math.floor((ms || 0) / 1000);
const p = n => String(n).padStart(2, '0');
return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`;
};
const fmtNum = n => String(Number(n || 0).toFixed(2)).replace('.', ',');
const header = ['ID', 'Kunde', 'Beginn', 'Ende', 'Dauer', 'Dauer (Minuten)', 'Betrag (EUR)', 'Tarif'];
const lines = [header.join(';')];
for (const r of rows) {
lines.push([
r.id,
esc(r.customer_name || ''),
fmtTs(r.start_time),
fmtTs(r.end_time),
fmtDur(r.duration),
fmtNum((r.duration || 0) / 60000),
fmtNum(r.amount),
r.rate_amount ? `${fmtNum(r.rate_amount)} EUR / ${r.rate_interval} Min` : ''
].join(';'));
}
const stamp = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung-${stamp}.csv"`);
res.send('' + lines.join('\r\n') + '\r\n');
}));
// ---------------------------------------------------------------------------
// Health & Fehlerbehandlung
// ---------------------------------------------------------------------------
app.get('/health', wrap(async (req, res) => {
const rate = await getRate();
const active = await dbGet(`SELECT COUNT(*) AS c FROM active_timers`);
res.json({
status: 'ok',
rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval,
activeTimers: active.c,
uptimeSeconds: Math.floor(process.uptime())
});
}));
app.use((err, req, res, next) => {
console.error('Unerwarteter Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
});
// ---------------------------------------------------------------------------
// Authentik-Discovery anstossen (nicht-blockierend; loggt das Ergebnis selbst)
oidc.init();
const server = app.listen(PORT, HOST, () => {
console.log(`Timer App laeuft auf http://${HOST}:${PORT}`);
console.log(`Cookie secure: ${COOKIE_SECURE} (bei false ist HTTP erlaubt)`);
});
function shutdown(signal) {
console.log(`${signal} empfangen - fahre herunter.`);
server.close(() => db.close(() => process.exit(0)));
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* Erzeugt einen bcrypt-Hash fuer die .env-Variable PASSWORD_HASH.
*
* Aufruf: npm run hash-password -- "MeinPasswort"
* or: node tools/hash-password.js "MeinPasswort"
*/
const bcrypt = require('bcrypt');
const password = process.argv[2];
if (!password) {
console.error('Aufruf: npm run hash-password -- "DasPasswort"');
process.exit(1);
}
if (password.length < 8) {
console.error('FEHLER: Bitte mindestens 8 Zeichen verwenden.');
process.exit(1);
}
const hash = bcrypt.hashSync(password, 12);
console.log('');
console.log('Diese Zeile in die .env eintragen (und PASSWORD entfernen):');
console.log('');
console.log(`PASSWORD_HASH=${hash}`);
console.log('');
+38
View File
@@ -0,0 +1,38 @@
# Caddy - Beispiel für die Timer App
#
# Caddy holt und erneuert das Zertifikat selbst. Bei dieser Variante
# in der .env COOKIE_SECURE=true und HOST=127.0.0.1 setzen.
timer.firma.example {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
}
reverse_proxy 127.0.0.1:3003 {
# /health antwortet ohne Anmeldung
health_uri /health
health_interval 30s
health_timeout 10s
}
log {
output file /var/log/caddy/timer.log {
roll_size 100mb
roll_keep 5
}
}
}
# ---------------------------------------------------------------------------
# Variante: nur im internen Netz, ohne öffentliches Zertifikat.
# Caddy stellt dann ein selbstsigniertes Zertifikat aus (Browser-Warnung beim
# ersten Aufruf). COOKIE_SECURE darf hier true bleiben, weil HTTPS gesprochen
# wird.
#
# https://timer.intern:443 {
# tls internal
# reverse_proxy 127.0.0.1:3003
# }
+55
View File
@@ -0,0 +1,55 @@
# nginx - Beispiel für die Timer App
#
# Bei dieser Variante in der .env:
# HOST=127.0.0.1
# TRUST_PROXY=1
# COOKIE_SECURE=true
server {
listen 80;
server_name timer.firma.example;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name timer.firma.example;
ssl_certificate /etc/letsencrypt/live/timer.firma.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/timer.firma.example/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# CSV-Export darf auch bei vielen Einträgen dauern
client_max_body_size 1m;
proxy_read_timeout 120s;
location / {
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Ohne diese Zeile hält die App die Verbindung für unverschlüsselt
# und das secure-Cookie kommt nicht zustande:
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ---------------------------------------------------------------------------
# Variante ohne TLS, rein intern (z. B. http://timer.intern):
# Dann in der .env COOKIE_SECURE=false setzen, sonst scheitert der Login stumm.
#
# server {
# listen 80;
# server_name timer.intern;
# location / {
# proxy_pass http://127.0.0.1:3003;
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+45
View File
@@ -0,0 +1,45 @@
[Unit]
Description=Timer App - Zeiterfassung
Documentation=file:///opt/timer-app/DEPLOY.md
After=network.target
[Service]
Type=simple
# --- Anpassen ---------------------------------------------------------------
WorkingDirectory=/opt/timer-app/backend
ExecStart=/usr/bin/node server.js
User=timerapp
Group=timerapp
# ----------------------------------------------------------------------------
Restart=always
RestartSec=10
Environment=NODE_ENV=production
# Zeitzone bestimmt die Tagesgrenzen im Historien-Filter
Environment=TZ=Europe/Berlin
# Ausgabe landet im Journal: journalctl -u timer-app -f
StandardOutput=journal
StandardError=journal
# --- Härtung ----------------------------------------------------------------
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
# Einziger Pfad mit Schreibrecht: das Datenverzeichnis
ReadWritePaths=/opt/timer-app/data
LimitNOFILE=4096
[Install]
WantedBy=multi-user.target
+564
View File
@@ -0,0 +1,564 @@
/**
* Timer App - Frontend
*
* Die Timer selbst leben auf dem Server. Der Browser zaehlt zwischen zwei
* Server-Abgleichen nur lokal weiter, damit die Anzeige fluessig laeuft.
* Gerechnet wird lokal ausschliesslich fuer die Anzeige - der verbindliche
* Betrag entsteht beim Beenden auf dem Server.
*/
const state = {
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
rate: { rateAmount: 10, rateInterval: 10 },
tickHandle: null,
syncHandle: null,
history: { offset: 0, limit: 50, reachedEnd: false }
};
const SYNC_INTERVAL_MS = 15000; // Abgleich mit dem Server
const TICK_INTERVAL_MS = 250; // Anzeige-Aktualisierung
let oidcConfigured = false; // ob Authentik erreichbar ist
let loggedInUser = null; // {username, displayName, email} nach SSO-Login
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function formatTime(ms) {
const total = Math.floor(Math.max(0, ms) / 1000);
const p = n => String(n).padStart(2, '0');
return `${p(Math.floor(total / 3600))}:${p(Math.floor((total % 3600) / 60))}:${p(total % 60)}`;
}
function formatEuro(value) {
return '€' + Number(value || 0).toLocaleString('de-DE', {
minimumFractionDigits: Number.isInteger(Number(value)) ? 0 : 2,
maximumFractionDigits: 2
});
}
function calcAmount(ms) {
if (!ms || ms <= 0) return 0;
const minutes = Math.ceil(ms / 60000);
const units = Math.ceil(minutes / state.rate.rateInterval);
return Math.round(units * state.rate.rateAmount * 100) / 100;
}
/** Aktuelle Zeit eines Timers - lokal fortgeschrieben seit dem letzten Sync. */
function currentMs(timer) {
if (!timer.isRunning) return timer.baseMs;
return timer.baseMs + (Date.now() - timer.syncedAt);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str == null ? '' : String(str);
return div.innerHTML;
}
let toastTimeout = null;
function toast(message, kind = 'info') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = 'toast show ' + kind;
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => { el.className = 'toast'; }, 4000);
}
async function api(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (response.status === 401) {
stopLoops();
showLogin();
throw new Error('Nicht angemeldet');
}
if (!response.ok) {
let msg = 'Serverfehler';
try { msg = (await response.json()).error || msg; } catch (e) { /* egal */ }
throw new Error(msg);
}
return response.status === 204 ? null : response.json();
}
// ---------------------------------------------------------------------------
// Ansichten
// ---------------------------------------------------------------------------
function switchView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function showLogin() {
document.getElementById('header').style.display = 'none';
switchView('loginView');
applyLoginMode();
}
// Anmeldung ausschliesslich ueber Authentik. Ist Authentik nicht erreichbar,
// wird der Hinweis unter dem Button eingeblendet.
function applyLoginMode() {
const err = document.getElementById('loginError');
if (err) err.classList.toggle('show', !oidcConfigured);
}
function showTimer() {
switchView('timerView');
}
async function showHistory() {
switchView('historyView');
state.history.offset = 0;
state.history.reachedEnd = false;
await loadHistory(true);
}
async function showSettings() {
switchView('settingsView');
await loadSettings();
}
// ---------------------------------------------------------------------------
// Anmeldung
// ---------------------------------------------------------------------------
async function checkAuth() {
try {
const data = await api('/api/auth/status');
oidcConfigured = !!data.oidcConfigured;
loggedInUser = data.user || null;
if (data.authenticated) {
await enterApp();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
async function enterApp() {
document.getElementById('header').style.display = 'flex';
renderCurrentUser();
showTimer();
await syncTimers();
startLoops();
}
// Zeigt den angemeldeten Authentik-Nutzer oben rechts im Header.
function renderCurrentUser() {
const el = document.getElementById('currentUser');
if (!el) return;
const u = loggedInUser;
el.textContent = u ? (u.displayName || u.username || '') : '';
el.title = u && u.email ? u.email : 'Angemeldet';
}
async function logout() {
const running = state.timers.filter(t => t.isRunning).length;
if (running > 0 && !confirm(
`${running} Timer ${running === 1 ? 'läuft' : 'laufen'} noch.\n\n` +
`Sie laufen serverseitig weiter und sind nach dem nächsten Anmelden wieder da. Trotzdem abmelden?`
)) return;
stopLoops();
state.timers = [];
// GET-Navigation: beendet lokale Sitzung und bei Authentik-Login auch die SSO-Sitzung
window.location.href = '/auth/logout';
}
// ---------------------------------------------------------------------------
// Timer-Synchronisation
// ---------------------------------------------------------------------------
async function syncTimers() {
const data = await api('/api/timers');
state.rate = data.rate;
const now = Date.now();
state.timers = data.timers.map(t => ({
id: t.id,
customerName: t.customerName,
isRunning: t.isRunning,
baseMs: t.elapsedMs,
syncedAt: now,
createdAt: t.createdAt
}));
renderTimers();
}
function startLoops() {
stopLoops();
state.tickHandle = setInterval(tick, TICK_INTERVAL_MS);
state.syncHandle = setInterval(() => { syncTimers().catch(() => {}); }, SYNC_INTERVAL_MS);
}
function stopLoops() {
clearInterval(state.tickHandle);
clearInterval(state.syncHandle);
state.tickHandle = null;
state.syncHandle = null;
}
// Nach Ruhezustand/Tabwechsel sofort abgleichen statt bis zum naechsten Sync warten
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.syncHandle) syncTimers().catch(() => {});
});
/** Aktualisiert nur die Zahlen - baut das DOM nicht neu (sonst springt der Fokus). */
function tick() {
for (const timer of state.timers) {
const ms = currentMs(timer);
const timeEl = document.getElementById(`time-${timer.id}`);
const amountEl = document.getElementById(`amount-${timer.id}`);
if (timeEl) timeEl.textContent = formatTime(ms);
if (amountEl) amountEl.textContent = formatEuro(calcAmount(ms));
}
}
// ---------------------------------------------------------------------------
// Timer-Darstellung
// ---------------------------------------------------------------------------
function renderTimers() {
const list = document.getElementById('timerList');
const empty = document.getElementById('timerEmpty');
document.getElementById('rateHint').textContent =
`Tarif: ${formatEuro(state.rate.rateAmount)} pro angefangene ${state.rate.rateInterval} Minuten`;
if (state.timers.length === 0) {
list.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
list.innerHTML = state.timers.map(timer => {
const ms = currentMs(timer);
const since = new Date(timer.createdAt).toLocaleTimeString('de-DE',
{ hour: '2-digit', minute: '2-digit' });
return `
<div class="timer-card ${timer.isRunning ? 'running' : 'paused'}" data-id="${timer.id}">
<div class="timer-card-head">
<input class="timer-name" type="text" value="${escapeHtml(timer.customerName)}"
placeholder="Name / Platz (optional)" maxlength="80"
onchange="renameTimer(${timer.id}, this.value)">
<span class="timer-badge">${timer.isRunning ? '● läuft' : '❚❚ pausiert'}</span>
</div>
<div class="timer-card-time" id="time-${timer.id}">${formatTime(ms)}</div>
<div class="timer-card-amount" id="amount-${timer.id}">${formatEuro(calcAmount(ms))}</div>
<div class="timer-card-since">gestartet um ${since} Uhr</div>
<div class="timer-card-actions">
${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>`}
<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>
</div>`;
}).join('');
}
// ---------------------------------------------------------------------------
// Timer-Aktionen
// ---------------------------------------------------------------------------
async function addTimer() {
const field = document.getElementById('newTimerName');
const name = field.value.trim();
try {
await api('/api/timers', { method: 'POST', body: JSON.stringify({ customerName: name }) });
field.value = '';
await syncTimers();
toast(name ? `Timer für „${name}" gestartet` : 'Timer gestartet', 'success');
} catch (e) {
toast('Timer konnte nicht gestartet werden: ' + e.message, 'error');
}
}
async function renameTimer(id, value) {
try {
await api(`/api/timers/${id}`, {
method: 'PATCH',
body: JSON.stringify({ customerName: value.trim() })
});
const timer = state.timers.find(t => t.id === id);
if (timer) timer.customerName = value.trim();
} catch (e) {
toast('Name konnte nicht gespeichert werden: ' + e.message, 'error');
}
}
async function pauseTimer(id) {
try {
await api(`/api/timers/${id}/pause`, { method: 'POST' });
await syncTimers();
} catch (e) {
toast('Pause fehlgeschlagen: ' + e.message, 'error');
}
}
async function resumeTimer(id) {
try {
await api(`/api/timers/${id}/resume`, { method: 'POST' });
await syncTimers();
} catch (e) {
toast('Fortsetzen fehlgeschlagen: ' + 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;
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');
} else {
toast(result.reason || 'Nichts gespeichert.', 'info');
}
} catch (e) {
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
}
}
async function discardTimer(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
const label = timer.customerName ? `${timer.customerName}"` : 'Timer';
if (!confirm(`${label} verwerfen?\n\nDie erfasste Zeit (${formatTime(currentMs(timer))}) wird NICHT gespeichert.`)) return;
try {
await api(`/api/timers/${id}`, { method: 'DELETE' });
await syncTimers();
toast('Timer verworfen - nichts gespeichert.', 'info');
} catch (e) {
toast('Verwerfen fehlgeschlagen: ' + e.message, 'error');
}
}
document.getElementById('newTimerForm').addEventListener('submit', (e) => {
e.preventDefault();
addTimer();
});
// ---------------------------------------------------------------------------
// Historie
// ---------------------------------------------------------------------------
function historyQuery() {
const params = new URLSearchParams();
const from = document.getElementById('filterFrom').value;
const to = document.getElementById('filterTo').value;
const q = document.getElementById('filterName').value.trim();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (q) params.set('q', q);
return params;
}
async function loadHistory(reset = false) {
if (reset) {
state.history.offset = 0;
state.history.reachedEnd = false;
}
const params = historyQuery();
params.set('limit', state.history.limit);
params.set('offset', state.history.offset);
try {
const data = await api('/api/sessions?' + params.toString());
renderHistory(data, reset);
} catch (e) {
document.getElementById('historyList').innerHTML =
`<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Fehler beim Laden: ${escapeHtml(e.message)}</p></div>`;
}
}
function renderHistory(data, reset) {
const listEl = document.getElementById('historyList');
const statsEl = document.getElementById('historyStats');
const moreBtn = document.getElementById('historyMore');
statsEl.innerHTML = `
<div class="stat-card"><h3>Einträge</h3><p>${data.totals.count}</p></div>
<div class="stat-card"><h3>Gesamtzeit</h3><p>${formatTime(data.totals.duration)}</p></div>
<div class="stat-card"><h3>Gesamtbetrag</h3><p>${formatEuro(data.totals.amount)}</p></div>
`;
if (data.totals.count === 0) {
listEl.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><p>Keine Einträge für diesen Zeitraum</p></div>';
moreBtn.style.display = 'none';
return;
}
const rows = data.sessions.map(s => {
const date = new Date(s.created_at * 1000).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const name = s.customer_name
? `<span class="history-item-name">${escapeHtml(s.customer_name)}</span>`
: '<span class="history-item-name muted">ohne Namen</span>';
return `
<div class="history-item">
<div class="history-item-info">
${name}
<div class="history-item-date">${date} Uhr</div>
<div class="history-item-duration">${formatTime(s.duration)}</div>
</div>
<div class="history-item-right">
<div class="history-item-amount">${formatEuro(s.amount)}</div>
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
</div>
</div>`;
}).join('');
if (reset) {
listEl.innerHTML = rows;
} else {
listEl.insertAdjacentHTML('beforeend', rows);
}
const shown = state.history.offset + data.sessions.length;
state.history.reachedEnd = shown >= data.totals.count;
moreBtn.style.display = state.history.reachedEnd ? 'none' : 'inline-block';
moreBtn.textContent = `Weitere laden (${shown} von ${data.totals.count})`;
}
async function loadMoreHistory() {
state.history.offset += state.history.limit;
await loadHistory(false);
}
async function deleteSession(id) {
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
try {
await api(`/api/sessions/${id}`, { method: 'DELETE' });
await loadHistory(true);
toast('Eintrag gelöscht', 'info');
} catch (e) {
toast('Löschen fehlgeschlagen: ' + e.message, 'error');
}
}
function exportCsv() {
const params = historyQuery();
window.location.href = '/api/sessions/export.csv?' + params.toString();
}
function resetFilter() {
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
document.getElementById('filterName').value = '';
loadHistory(true);
}
function filterToday() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('filterFrom').value = today;
document.getElementById('filterTo').value = today;
loadHistory(true);
}
function filterThisMonth() {
const now = new Date();
const first = new Date(now.getFullYear(), now.getMonth(), 1);
const pad = n => String(n).padStart(2, '0');
document.getElementById('filterFrom').value =
`${first.getFullYear()}-${pad(first.getMonth() + 1)}-01`;
document.getElementById('filterTo').value =
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
loadHistory(true);
}
['filterFrom', 'filterTo'].forEach(id =>
document.getElementById(id).addEventListener('change', () => loadHistory(true)));
document.getElementById('filterName').addEventListener('input', (() => {
let handle = null;
return () => { clearTimeout(handle); handle = setTimeout(() => loadHistory(true), 350); };
})());
// ---------------------------------------------------------------------------
// Einstellungen (serverseitig - gelten fuer alle Geraete)
// ---------------------------------------------------------------------------
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;
updateSettingsPreview();
} catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
}
}
async function saveSettings() {
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
const rateInterval = parseInt(document.getElementById('rateInterval').value);
try {
const rate = await api('/api/settings', {
method: 'PUT',
body: JSON.stringify({ rateAmount, rateInterval })
});
state.rate = rate;
renderTimers();
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
showTimer();
} catch (e) {
toast('Speichern fehlgeschlagen: ' + e.message, 'error');
}
}
async function resetSettings() {
if (!confirm('Tarif auf Standard zurücksetzen (€10 pro 10 Minuten)?')) return;
document.getElementById('rateAmount').value = 10;
document.getElementById('rateInterval').value = 10;
await saveSettings();
}
function updateSettingsPreview() {
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
document.getElementById('settingsPreview').textContent =
`${formatEuro(amount)} pro angefangene ${interval} Minuten`;
const examples = [Math.max(1, interval - 5), interval + 5, interval * 2, 60]
.filter((v, i, a) => a.indexOf(v) === i)
.map(mins => `${mins} Min = ${formatEuro(Math.ceil(mins / interval) * amount)}`);
document.getElementById('previewExamples').innerHTML =
`<strong>Beispiele:</strong><br>${examples.join(' | ')}`;
}
document.getElementById('settingsForm').addEventListener('submit', (e) => {
e.preventDefault();
saveSettings();
});
document.getElementById('rateAmount').addEventListener('input', updateSettingsPreview);
document.getElementById('rateInterval').addEventListener('input', updateSettingsPreview);
// ---------------------------------------------------------------------------
checkAuth();
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+738
View File
@@ -0,0 +1,738 @@
<!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"] {
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 {
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); }
/* 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;
}
.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;
}
.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-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=20260803-sso"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB