Compare commits

..

58 Commits

Author SHA1 Message Date
admin 9363e17de3 Statistik: dritte Kachel 'Führungen' und Umbenennung 'Alle Events'
Mittlere Kachel von 'Führungen' in 'Alle Events' umbenannt (zeigt
Gesamtzahl aller Events) und neue Kachel 'Führungen' mit der Summe
aller Führungen ergänzt. Bump auf 1.10.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:22:33 +02:00
admin 978ed4e1da chore: feat/objekte-kategorie → main (Version 1.10.1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:14:55 +02:00
admin 7475d4fd37 feat: Objekte-Kategorien (stern/sonne/beide) — Version 1.10.1
- Neue Spalte Kategorie (SET stern/sonne) in objekte-Tabelle
- ObjektSelector zeigt je nach ArtFuehrung nur passende Objekte
- SonnenFührung: Sonne fest vorausgewählt, zusätzliche Sonne-Objekte wählbar
- Bestehende Objekte erhalten Kategorie automatisch beim Speichern
- Admin: Kategorie editierbar (stern / sonne / stern,sonne)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:14:52 +02:00
admin 96ba03b909 fix: grafikProxy gibt 404 statt HTML zurück für JS/CSS-Assets bei Upstream-Fehler
Verhindert MIME-Type-Fehler im Browser wenn upstream-Server für
JS/CSS-Dateien eine HTML-Fehlerseite zurückgibt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 18:22:53 +02:00
admin 421b589169 fix: host.docker.internal für PHP-Bridge im Prod-Container
extra_hosts in docker-compose.prod.yml damit der Container
host.docker.internal:8080 (Sternwarte) erreichen kann.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:55:45 +02:00
admin d13e3b0ba9 chore: migrateDatabase → main (Version 1.10.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:48:18 +02:00
admin a75303f857 feat: Version 1.10.0 — DB-Zugriff auf PHP-Bridge (DB4js_all.php) umgestellt
- lib/db.ts entfernt, mysql2-Abhängigkeit gestrichen
- lib/phpdb.ts: HTTP-Client für alle DB-Operationen via DB4js_all.php
- Alle API-Routen und Server Actions auf phpdb.ts umgestellt
- compose.yml / docker-compose.prod.yml: MySQL/phpMyAdmin-Container entfernt
- app/api/DB4js_all.php/route.ts: Proxy für Statistik-AJAX-Calls
- Statistik-Grafik liest ab 2026 live aus logbuch statt StatistikJahre
- PHP 7.3-Kompatibilität: str_contains → strpos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:48:15 +02:00
admin c3f0b8f1e0 chore: Version 1.9.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:06:34 +02:00
admin 67dc253cd9 docs: anleitung.html aus ANLEITUNG.md aktualisiert
Führungsbuch statt Logbuch, Statistik-Sektion mit 4 Kacheln und
Grafik-Button, Suchfeld-Beschreibung aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:03:51 +02:00
admin 39bb94ebb7 fix: public-Ordner in Docker-Runner-Stage kopieren
Next.js standalone serviert /public nicht automatisch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:50:33 +02:00
admin 7571b14422 chore: Version 1.9.0 — SSH-Backup nach Logbuch-Eintrag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:57:37 +02:00
admin d88005d9fe feat: manueller Backup-Button für Admin
POST /api/backup (nur Admin) löst triggerBackup() aus.
Button im Header zeigt /✓/✗ Feedback, verschwindet nach 4s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:52:26 +02:00
admin 00a3f02d80 fix: lokale Dump-Datei immer löschen (try/finally)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:23:05 +02:00
admin 43ddbbcf72 feat: lokalen Dump nach erfolgreichem Upload löschen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:16:58 +02:00
admin 49563e6bd0 fix: backup — Remote-Verzeichnis per mkdir -p vor scp anlegen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:05:38 +02:00
admin 4316670ce4 fix: env_file statt Variablen-Substitution in docker-compose
env_file: .env lädt alle Variablen direkt in den Container,
unabhängig vom CWD beim docker-compose-Aufruf. environment:
überschreibt nur noch die drei Werte die vom .env abweichen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:28:54 +02:00
admin a12c62bbdc fix: AUTH_SECRET-Check lazy — wirft erst zur Laufzeit, nicht beim Build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:19:25 +02:00
admin 072ca040bb fix: .env nicht ins Docker-Image — Variablen kommen aus Compose/ENV
.dockerignore schließt .env aus dem Build-Kontext aus. Next.js
standalone hat dadurch keine eingebettete .env mehr und liest
Variablen sauber aus process.env (gesetzt via docker-compose
environment:). NEXT_PUBLIC_FAHRKOSTEN_SATZ bleibt als Build-ARG
verfügbar (Default 15). BACKUP_SSH_KEY_FILE default /dev/null
damit Compose auch ohne Backup-Konfiguration startet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:18:15 +02:00
admin 52234132ca fix: Dump via mysql2 statt mariadb-dump
MariaDB-Client kennt caching_sha2_password (MySQL-8-Default) nicht.
Der mysql2-Node.js-Treiber implementiert das Plugin nativ und
verbindet sich problemlos. Der Dump schreibt CREATE TABLE +
INSERT-Batches (200 Zeilen) direkt via gzip in die lokale Datei.
Keine externen Binaries mehr für den Dump-Schritt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:54:41 +02:00
admin 93b449412f fix: backup — mysql_native_password für MariaDB-Client gegen MySQL 8
mariadb-dump kennt caching_sha2_password nicht (MySQL-8-Default).
--default-auth=mysql_native_password umgeht das. Außerdem wird
der Exit-Code des Dump-Prozesses jetzt ausgewertet — fehlerhafte
Dumps werden erkannt statt als leere Datei abgelegt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:44:22 +02:00
admin c3bac456e7 refactor: backup schreibt Dump zuerst lokal, dann scp
Statt direkter pipe dump→gzip→ssh wird der Dump jetzt in
BACKUP_LOCAL_DIR (default /tmp/logbuch-backup) geschrieben
und danach per scp übertragen. So ist der Dump jederzeit
im Container einsehbar; SSH bleibt optional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:23:59 +02:00
admin fb0b64c36c fix: backup blockiert HTTP-Response nicht mehr
setImmediate() startet Backup außerhalb des Next.js Request-Kontexts,
ConnectTimeout=15 verhindert hängende SSH-Verbindungen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:06:17 +02:00
admin 2e875ed1ad fix: backup — mariadb-dump, --skip-ssl, SSH-Key per Volume in Compose
- mariadb-dump statt mysqldump (kein Deprecation-Warning in Alpine)
  via BACKUP_DUMP_CMD konfigurierbar (Fallback für lokale MySQL-Umgebung)
- --skip-ssl unterdrückt MariaDB-SSL-Warnung bei MYSQL_PWD-Nutzung
- docker-compose.prod.yml: BACKUP_SSH_URL + Key-Volume (BACKUP_SSH_KEY_FILE)
  damit SSH-Alias-Auflösung und Key-Zugriff im Container funktionieren

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:54:43 +02:00
admin d99a696ef0 fix: backup — MYSQL_PWD statt -p Flag, SSH-Key optional
Passwort via MYSQL_PWD-Env statt -p vermeidet die mysqldump-Warnung
und ist sicherer. BACKUP_SSH_KEY_PATH ist jetzt optional: wenn leer,
wird kein -i übergeben und SSH nutzt seine eigene Konfiguration
(~/.ssh/config, ssh-agent). So funktionieren SSH-Config-Aliases
(z.B. 'strato_1') ohne Key-Override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:46:54 +02:00
admin 10b52d268e fix: backup — error-Events auf spawn abfangen, Tilde in Key-Pfad expandieren
Ohne 'error'-Handler auf den Child-Prozessen führt spawn ENOENT zu
uncaughtException statt zu einem gefangenen Promise-Reject. Außerdem
wird '~' im BACKUP_SSH_KEY_PATH jetzt manuell zu $HOME expandiert,
da spawn keine Shell-Expansion macht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:29:38 +02:00
admin cf95f3027f feat: automatisches SSH-Backup nach jedem Logbuch-Eintrag
Nach jedem POST und PUT im Logbuch wird mysqldump (ohne beos-Tabelle)
via gzip | ssh auf einen externen Server übertragen. Backups älter als
30 Tage werden automatisch gelöscht. BACKUP_SSH_URL und
BACKUP_SSH_KEY_PATH in .env konfigurieren; SSH-Key als Volume mounten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:21:01 +02:00
admin 8c60089325 fix: iOS/iPad text color — text-gray-900 on all inputs, tables, headings
Alle Eingabefelder, Tabellenzellen und Überschriften ohne explizite
Textfarbe wurden mit text-gray-900 versehen. iOS rendert sonst
system-default-Farben, die auf weißem Hintergrund kaum lesbar sind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:07:18 +02:00
admin 3ab4779ee5 chore: Version 1.8.0 — Führungsbuch Umbenennung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:41:57 +02:00
admin 9cb22b1a53 Merge branch 'umbenannt': Logbuch → Führungsbuch (UI-Strings) 2026-06-02 22:41:47 +02:00
admin 50b74c4e92 Umbenennung. Logbuch -> Führungsbuch 2026-06-02 22:40:54 +02:00
admin e73680a70d fix: middleware.ts → proxy.ts (Next.js 16 Konvention)
Next.js 16 erwartet proxy.ts mit export function proxy(),
nicht middleware.ts. Deprecation-Warnung damit beseitigt.
CLAUDE.md mit korrekter Konvention aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:12:16 +02:00
admin 1f70940dce fix: Middleware aktiviert — proxy.ts → middleware.ts
proxy.ts wurde nie als Next.js-Middleware erkannt (falscher Dateiname,
falscher Exportname). Die mustChangePassword-Weiterleitung und der
Login-Guard liefen daher nie. Zusätzlich Fallback-Secret entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:07:21 +02:00
admin a59afefb07 Merge branch 'backup': backup_db.sh dazu gebaut 2026-05-31 17:44:39 +00:00
admin ce365e3e79 V 1.7.9: backup_db.sh dazu gebaut 2026-05-31 17:42:07 +00:00
admin 69c31637bb Remove CI/CD workflows and docs 2026-05-31 15:57:17 +00:00
admin 1ef4f0d9da CI: add Drone pipeline and clarify Gitea auto-deploy support
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:55:05 +00:00
admin b4f57be2e7 Test commit
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:47:58 +00:00
admin 03c65c78ed Docs: add Gitea instructions for CI secrets
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:41:48 +00:00
admin ac77606475 Docs: add CI/CD secrets instructions to ANLEITUNG.md
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:38:27 +00:00
admin d718d78210 CI: add GitHub Actions workflow to build and push Docker image
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-31 15:37:17 +00:00
admin 27f2d438e2 Bump version to 1.7.8 2026-05-31 15:34:34 +00:00
admin d94de334d7 V1.7.7 Version updatet 2026-05-31 13:32:54 +00:00
admin d5bd359802 feat: Statistik komplett neu gestaltet — kuppelunabhängig, feste Spalten
- Statistik gilt jetzt für alle Kuppeln gemeinsam (kein Kuppel-Filter mehr)
- Neue Tabellenstruktur: Besucher (RF/SF/SonF/PrF/ToT/Gesamt) | Anzahl (Führ./Beob./TD/Sonst./BEOS/ToT/Gesamt)
- Anzahl zählt Einträge (COUNT(*)) statt distinkte Tage
- Zusammenfassungskacheln auf 2 reduziert (Besucher + Führungen gesamt)
- Fix: Besucher-State wird zurückgesetzt wenn ArtFuehrung auf TD/BEOS wechselt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:29:08 +02:00
admin 102bc441c6 erster Versuch mit Fahrkosten 2026-05-27 22:33:41 +02:00
admin aad25109da 'Keine Einträge vorhanden bei der List angepasst 2026-05-27 18:05:04 +02:00
admin 10c6554276 v1.7.6: Bearbeiten/Löschen auf BEO-Mitglieder beschränkt
Ändern und Löschen eines Eintrags ist nur noch für angemeldete BEOs des Eintrags möglich (Admins dürfen immer). Serverseitige Prüfung via logbuch_beos-Tabelle; clientseitig werden die Aktions-Buttons nur eingeblendet, wenn der User in der BEO-Liste steht.

Außerdem: setState-in-Effect-Linterfehler in LogbuchList behoben (abgeleiteter Loading-State, abgeleitetes Page-Reset via filterKey).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:17:00 +02:00
admin d56ebb229d feat: Anleitung-Button im Footer + Statistik-Effect-Fix
- Footer: 3-Spalten-Layout mit 'Anleitung'-Button in der Mitte (Link auf /anleitung.html)
- anleitung.html: 'Zurück zum Logbuch'-Button oben (beim Drucken ausgeblendet)
- Statistik: synchrones setState im Effect durch abgeleiteten loading/error-State ersetzt; Fetch-Abbruch mit cancelled-Flag; as any durch ArtFuehrung-Cast ersetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:46:38 +02:00
admin 42a2651f8e docs: Anleitung aktualisiert und als HTML-Datei ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:56:51 +02:00
admin b588a70ecd v1.7.5: UI-Verbesserungen Listenansicht und Zeiteingabe
- Listview: Toolbar-Rahmen #407BFF, Fokus 2px ring-inset #235CC8
- Listview: Pfeil-Buttons Monatsauswahl in #85B7D7
- Listview: Klimawerte werden bei allen-0 ausgeblendet
- Zeiteingabe: nur Stunden + Tab setzt Minuten automatisch auf :00

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:21:05 +02:00
admin 9e2f430d4a v1.7.4: Suche in Listenansicht (Bemerkungen, Objekte, BEOs)
Suchfeld in der Toolbar der Listenansicht: Suche über alle Einträge
der Kuppel in Bemerkungen, Objekte und BEOs. Monatsauswahl, Suchfeld
und Drucken-Button in einer Zeile; Monatsauswahl wird bei aktiver
Suche unsichtbar aber platzhaltend ausgeblendet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:45:09 +02:00
admin d5ceff74be v1.7.3: Autofokus auf Startzeit statt Endzeit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:08:21 +02:00
admin 1a34fccc35 v1.7.2: Zeiteingabe – Startzeit leer, Endzeit vorbelegt und fokussiert
- Startzeit startet leer (manuelle Eingabe erforderlich)
- Endzeit startet mit aktueller Uhrzeit auf 5 Minuten aufgerundet
- Endzeit erhält Autofokus beim Laden des Formulars
- Startzeit synct Endzeit nicht mehr automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:01:09 +02:00
admin cf038ad3be fix: Temperatureingabe negativ, Monatsauswahl auf aktuellem Monat begrenzt
- LogbuchForm: Temperatur als String-State (tempRaw) – Minus-Vorzeichen tippbar
- LogbuchList: input[type=month] mit max=currentMonth(), kein Zukunftsmonat wählbar
- .gitignore: DB_BACKUP/ ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:47:44 +02:00
admin 4593713042 feat: ObjektSelector – Dropdown bleibt nach Auswahl offen
Mehrere Objekte können ohne Schließen des Dropdowns ausgewählt werden.
Fokus kehrt nach jeder Auswahl ins Suchfeld zurück.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:47:08 +02:00
admin edb324719b feat: Admin – Objektverwaltung mit Tab-Navigation
Admins können Objekte anlegen, umbenennen und löschen.
Die Admin-Seite ist in zwei Tabs aufgeteilt: Benutzerverwaltung (?tab=benutzer)
und Objektverwaltung (?tab=objekte), navigierbar per URL-Parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:36:56 +02:00
admin 845b634804 v1.7.1: Druck – kleinere Schrift und schmalere Tabelle
Beim Drucken: Schrift 0.72rem (!important gegen Inline-Style),
Tabellenbreite 95%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:50:51 +02:00
admin 8fabf7bb30 feat: Druck – kleinere Schrift und chronologische Reihenfolge
Beim Drucken wird die Tabellenschrift auf 0.72rem verkleinert.
Der Drucken-Button lädt vorab alle Einträge des Monats in
aufsteigender Reihenfolge (älteste zuerst) und ruft dann
window.print() auf. API unterstützt jetzt order=asc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:19:06 +02:00
admin b18dfbe3f8 feat: ObjektSelector – Neu-Button entfernt, unbekannte Objekte direkt eintragen
Kein separater „+ Neu"-Button mehr. Im Dropdown erscheint stattdessen
„+ ‚[Name]' hinzufügen", sobald der eingegebene Text nicht bekannt ist.
Enter-Taste: bei einem Treffer auswählen, bei keinem Treffer neu anlegen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:40:32 +02:00
51 changed files with 2574 additions and 787 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Logbuch Dev",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 3306],
"portsAttributes": {
"3000": {
"label": "Next.js Dev Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "silent"
}
},
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next"
]
}
}
}
+17
View File
@@ -0,0 +1,17 @@
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:20
volumes:
- ..:/workspace:cached
environment:
DB_HOST: host.docker.internal
DB_USER: logbuch
DB_PASS: Ds!?f?f6X5B2
DB_NAME: sternwarte
DB_PORT: 3336
AUTH_SECRET: 75c3075e200d50f2273c60edcea5aca58796831e2c99ce2a69fca0005d5920cd
DEFAULT_PASSWORD: welzheim
NODE_ENV: development
extra_hosts:
- "host.docker.internal:host-gateway"
command: sleep infinity
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
.git
.gitignore
node_modules
.next
*.md
+1
View File
@@ -40,3 +40,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
backup.git/
DB_BACKUP/
+2 -2
View File
@@ -1,9 +1,9 @@
### Zugriff zur Datenbank:
Die Remote-Datenbank auf logbuch.fuerst-.stuttgart.de wird zum lokalen entwickeln über einen SSH-Tunner erreicht:
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
Dieser ist vor dem Starten des Programme einmal einzurichten!!
+180
View File
@@ -0,0 +1,180 @@
# Bedienungsanleitung Führungsbuch Sternwarte Welzheim
## Inhaltsverzeichnis
1. [Anmelden](#1-anmelden)
2. [Grundaufbau der App](#2-grundaufbau-der-app)
3. [Eintrag erfassen (Tab „Eingabe")](#3-eintrag-erfassen-tab-eingabe)
4. [Einträge einsehen und verwalten (Tab „Liste")](#4-einträge-einsehen-und-verwalten-tab-liste)
5. [Jahresstatistik (Tab „Statistik")](#5-jahresstatistik-tab-statistik)
6. [Drucken](#6-drucken)
7. [Administration (nur Admins)](#7-administration-nur-admins)
---
## 1. Anmelden
Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
- **Passwort**: individuell gesetztes Passwort
Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
---
## 2. Grundaufbau der App
### Kuppel-Auswahl
Oben befinden sich vier Reiter für die vier Kuppeln:
| Reiter | Bedeutung |
|--------|-----------|
| West | West-Kuppel |
| Ost | Ost-Kuppel |
| Süd | Süd-Kuppel |
| Pluto | Pluto-Kuppel |
Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.
### Funktions-Tabs
Unterhalb der Kuppelauswahl gibt es drei Tabs:
| Tab | Funktion |
|-----|----------|
| **Eingabe** | Neuen Eintrag anlegen oder bestehenden bearbeiten |
| **Liste** | Alle Einträge monatsweise ansehen, bearbeiten oder löschen |
| **Statistik** | Jahresübersicht Besucher und Führungen |
---
## 3. Eintrag erfassen (Tab „Eingabe")
### Pflichtfelder
**Art der Führung** Auswahl aus dem Dropdown:
| Kürzel | Bedeutung |
|--------|-----------|
| regulär | Reguläre öffentliche Führung |
| sonder | Sonderführung (für Gruppen, Schulen etc.) |
| sonnen | Sonnenführung |
| privat | Privatführung |
| BEOS | BEO-Sitzung (keine Besucher/Objekte) |
| TD | Treff/Diskussion (keine Besucher/Objekte) |
| Beobachtung | Reine Beobachtung ohne Führung |
| ToT | Teleskop ohne Termin |
| Sonstiges | Sonstige Veranstaltung |
**Datum** Datum der Veranstaltung (Standardwert: heute).
**Startzeit / Endzeit** Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. `8` oder `23`) und das Feld verlassen, werden die Minuten automatisch auf `00` gesetzt.
**Besucher** Anzahl der Besucher (nicht bei BEOS und TD).
### Optionale Felder
**Name / Gruppe** erscheint nur bei Sonderführung; Name der Gruppe oder Person.
**BEOs** beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.
**Beobachtete Objekte** nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:
- Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.
- Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.
- Noch unbekannte Objekte einfach eintippen am Ende der Dropdown-Liste erscheint dann **+ „[Name]" hinzufügen**. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.
- Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.
**Bemerkungen** freier Text, max. 500 Zeichen.
**Wetterdaten** Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. `-5`) können direkt eingegeben werden.
### Eintrag speichern
Schaltfläche **Eintrag speichern** unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.
> Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau.
### Eintrag bearbeiten
Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung **Änderungen speichern** klicken oder mit **Abbrechen** verwerfen.
---
## 4. Einträge einsehen und verwalten (Tab „Liste")
### Werkzeugleiste
Die Werkzeugleiste oben in der Liste enthält in einer Zeile:
- **Monatsnavigation** Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; **Aktueller Monat** springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).
- **Suchfeld** Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.
- **🖨 Drucken** siehe Abschnitt [Drucken](#6-drucken).
### Tabelleninhalt
Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte **fettgedruckt** und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.
### Eintrag bearbeiten
Stift-Symbol ✎ rechts in der Zeile.
### Eintrag löschen
× rechts in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich**.
### Seitennavigation
Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.
---
## 5. Jahresstatistik (Tab „Statistik")
Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.
- **Jahr** oben links änderbar (Eingabefeld).
- Darunter vier Kennzahlen-Kacheln:
- Kumulierte Besucher des Jahres für die gewählte Kuppel
- Führungstage des Jahres für die gewählte Kuppel
- Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)
- Führungstage für die gesamte Sternwarte
- **Grafik** Über diesen Button kann die (bekannte) Statistik-Grafik aufgerufen werden. Sie wird in ein einem gesonderten Fenster angezeigt. Zurück zum Führungsbuch kommt man über den Tab-Wechsel im Browser.
---
## 6. Drucken
Im Tab **Liste**: Schaltfläche **🖨 Drucken** oben rechts in der Liste.
- Es werden **alle Einträge des aktuell gewählten Monats** geladen (nicht nur die angezeigte Seite).
- Die Reihenfolge ist beim Ausdruck **chronologisch** (ältester Eintrag zuerst).
- Navigations- und Aktionselemente werden ausgeblendet; oben erscheint ein Kopfzeile mit Kuppelname und Druckdatum.
- Seitenformat: A4 Hochformat, Rand 1,5 cm.
Im Tab **Statistik**: ebenfalls eine **🖨 Drucken**-Schaltfläche für die Jahresstatistik.
---
## 7. Administration (nur Admins)
Erreichbar über die Schaltfläche **Admin** oben rechts (nur für Benutzer mit Admin-Rolle sichtbar).
Die Admin-Seite hat zwei Tabs:
### Benutzerverwaltung
Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.
**Passwort zurücksetzen**: Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort `welzheim` verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.
### Objektverwaltung
Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken.
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
- **Objekt löschen**: × in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.
+1 -1
View File
@@ -16,7 +16,7 @@ No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Do
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. Middleware lives in `proxy.ts` (Next.js 16 convention) and exports `middleware` (not `proxy`).
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`.
+5
View File
@@ -12,7 +12,9 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DATE
ARG NEXT_PUBLIC_FAHRKOSTEN_SATZ=15
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_PUBLIC_FAHRKOSTEN_SATZ=${NEXT_PUBLIC_FAHRKOSTEN_SATZ}
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
@@ -23,11 +25,14 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache mysql-client openssh-client
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
+32
View File
@@ -34,3 +34,35 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Neue Features (Statistik Grafik Proxy)
- Proxy für das interne Statistik-Portal:
- Neuer Server-seitiger Proxy unter `/api/statistik/grafik` (und Catch-all `/api/statistik/grafik/*`).
- Holt die Statistik-Seite serverseitig mit Basic-Auth und gibt sie an den Browser weiter, damit Zugangsdaten nicht im Client landen.
- Leitet auch CSS/JS/Images und AJAX-POSTs durch den Proxy (weitergeleitete Methoden und Bodies werden erhalten).
- Die HTML-Antwort wird bereinigt und relative URLs so umgeschrieben, dass Assets über die Proxy-URL geladen werden (`<base href="/api/statistik/grafik/">`).
- Environment-Variablen (server-side only):
- `STATISTIK_GRAFIK_URL` — Basis-URL des internen Statistik-Portals (z. B. `https://sternwarte-welzheim.de/intern/statistik`).
- `STATISTIK_GRAFIK_USER` — Benutzername für Basic-Auth.
- `STATISTIK_GRAFIK_PASS` — Passwort für Basic-Auth.
- UI-Änderung:
- Der `Grafik`-Button in der Statistik-Ansicht öffnet jetzt die Statistik-Seite in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, da Browser (insb. Firefox/Safari) bei Einbettung Probleme mit X-Frame-Options/CSP gemacht haben.
- Middleware / Sicherheit:
- Die Proxy-Route wird in der App-Auth-Middleware erlaubt, so dass der Proxy die Statistik-Seite auch ohne Benutzer-Session laden kann (Zugangskontrolle erfolgt über die serverseitigen Basic-Auth-Variablen).
- Datenbank-Backup:
- Das neue Skript `backup_db.sh` erzeugt ein tägliches MySQL-Dump-Backup im Verzeichnis `DB_BACKUP/`.
- Standardmäßig werden Backups älter als 7 Tage automatisch gelöscht.
- Beispiel-Cron-Eintrag für täglich 02:00 Uhr:
```cron
0 2 * * * cd /pfad/zum/repo && DB_ROOT_PASS=deinrootpass DB_NAME=sternwarte ./backup_db.sh --days 7
```
Hinweis: Speichere sensible Zugangsdaten nicht in Repositories. Setze die drei `STATISTIK_...` Variablen in deiner Deployment-Umgebung (z. B. Docker secrets, CI/CD environment variables oder auf dem Server). Die Proxy-Implementierung entfernt framing-blockierende Header und schiebt relative Asset-Pfade durch den Proxy, um Kompatibilitätsprobleme mit Browsern zu vermeiden.
Test, ob das nun Deployed, wenn auf main gepushed wird.
+85 -25
View File
@@ -6,6 +6,7 @@ import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList';
import Statistik from '@/components/Statistik';
import Fahrkosten from '@/components/Fahrkosten';
import packageJson from '@/package.json';
interface Props {
@@ -17,9 +18,12 @@ interface Props {
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik'>('eingabe');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
const grafikSrc = '/api/statistik/grafik';
const version = packageJson.version;
const buildDate =
@@ -44,24 +48,48 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
window.location.href = '/login';
}
async function handleBackup() {
setBackupState('running');
try {
const r = await fetch('/api/backup', { method: 'POST' });
setBackupState(r.ok ? 'ok' : 'error');
} catch {
setBackupState('error');
}
setTimeout(() => setBackupState('idle'), 4000);
}
return (
<div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF4FF] print:max-w-none print:border-0 print:p-0 print:bg-white">
{/* Header */}
<div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden">
<h1 className="text-xl sm:text-2xl font-bold leading-tight">
<h1 className="text-xl sm:text-2xl font-bold leading-tight text-gray-900">
<span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Logbuch für {activeKuppel}-Kuppel
Führungsbuch für {activeKuppel}-Kuppel
</h1>
<div className="flex items-center gap-2 shrink-0">
{role?.includes('admin') && (
<a
href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Admin
</a>
<>
<button
onClick={handleBackup}
disabled={backupState === 'running'}
className={`text-xs sm:text-sm px-2 sm:px-3 py-1.5 rounded-lg text-gray-900 disabled:opacity-50 ${
backupState === 'ok' ? 'bg-green-200' :
backupState === 'error' ? 'bg-red-200' :
'bg-gray-200 hover:bg-gray-300'
}`}
>
{backupState === 'running' ? '⏳ Backup…' : backupState === 'ok' ? '✓ Backup OK' : backupState === 'error' ? '✗ Fehler' : '💾 Backup'}
</button>
<a
href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Admin
</a>
</>
)}
<button
onClick={handleLogout}
@@ -92,17 +120,22 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{/* Eingabe/Liste/Statistik-Tabs */}
<div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden">
{(['eingabe', 'liste', 'statistik'] as const).map((tab) => (
{(['eingabe', 'liste', 'statistik', 'fahrkosten'] as const)
.filter((tab) => tab !== 'fahrkosten' || role?.includes('admin') || role?.includes('master'))
.map((tab) => (
<button
key={tab}
onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }}
onClick={() => {
setActiveTab(tab);
if (tab === 'eingabe') setEditEntry(null);
}}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : 'Statistik'}
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'}
</button>
))}
</div>
@@ -139,6 +172,8 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
currentUserKuerzel={kuerzel}
isAdmin={role?.includes('admin') ?? false}
limit={5}
compact
/>
@@ -150,23 +185,16 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{/* Liste-Tab: vollständige Liste */}
{activeTab === 'liste' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Einträge {activeKuppel}-Kuppel</span>
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
</div>
<div className="hidden print:block mb-4">
<div className="text-lg font-bold">Sternwarte Welzheim Logbuch {activeKuppel}-Kuppel</div>
<div className="text-lg font-bold">Sternwarte Welzheim Führungsbuch {activeKuppel}-Kuppel</div>
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div>
<LogbuchList
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
currentUserKuerzel={kuerzel}
isAdmin={role?.includes('admin') ?? false}
limit={15}
/>
</div>
@@ -174,9 +202,33 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{/* Statistik-Tab */}
{activeTab === 'statistik' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden gap-2">
<span className="text-sm font-semibold text-gray-900">Statistik (alle Kuppeln)</span>
<div className="flex items-center gap-2">
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
<button
onClick={() => window.open(grafikSrc, '_blank')}
className="text-sm px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
📊 Grafik
</button>
</div>
</div>
<Statistik />
</div>
)}
{/* Fahrkosten-Tab */}
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Statistik {activeKuppel}-Kuppel</span>
<span className="text-sm font-semibold text-gray-900">Fahrkostenabrechnung</span>
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
@@ -184,16 +236,24 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
🖨 Drucken
</button>
</div>
<Statistik kuppel={activeKuppel} />
<Fahrkosten />
</div>
)}
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
<footer className="mt-6 grid grid-cols-3 items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
rxf@gmx.de
</a>
</div>
<div className="flex justify-center">
<a
href="/anleitung.html"
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Anleitung
</a>
</div>
<div className="text-right">
v{version} {buildDate}
</div>
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ObjektRow } from './actions';
interface Props {
initialObjekte: ObjektRow[];
}
export default function ObjekteManager({ initialObjekte }: Props) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editKategorie, setEditKategorie] = useState<string>('stern');
const [newName, setNewName] = useState('');
const [newKategorie, setNewKategorie] = useState<string>('stern');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSaveEdit(id: number) {
const trimmed = editName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, kategorie: editKategorie }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Speichern.');
return;
}
setEditingId(null);
router.refresh();
}
async function handleDelete(id: number, name: string) {
if (!confirm(`Objekt „${name}" wirklich löschen?`)) return;
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, { method: 'DELETE' });
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Löschen.');
return;
}
router.refresh();
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, kategorie: newKategorie }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Erstellen.');
return;
}
setNewName('');
router.refresh();
}
return (
<div>
{error && (
<p className="mb-3 text-sm text-red-600 font-medium">{error}</p>
)}
<form onSubmit={handleCreate} className="flex gap-2 mb-4">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neues Objekt…"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
/>
<select
value={newKategorie}
onChange={(e) => setNewKategorie(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
<button
type="submit"
disabled={busy}
className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Hinzufügen
</button>
</form>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Kategorie</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th>
<th className="px-4 py-3 w-36"></th>
</tr>
</thead>
<tbody>
{initialObjekte.map((obj, idx) => (
<tr key={obj.ID} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{obj.ID}</td>
<td className="px-4 py-2">
{editingId === obj.ID ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(obj.ID);
if (e.key === 'Escape') setEditingId(null);
}}
autoFocus
className="w-full px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
/>
) : (
obj.Name
)}
</td>
<td className="px-4 py-2 hidden sm:table-cell">
{editingId === obj.ID ? (
<select
value={editKategorie}
onChange={(e) => setEditKategorie(e.target.value)}
className="px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
) : (
<span className="flex gap-1">
{obj.Kategorie.split(',').map((k) => (
<span key={k} className={`text-xs px-2 py-0.5 rounded-full font-medium ${k === 'sonne' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
{k}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-500 hidden sm:table-cell">
{obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'}
</td>
<td className="px-4 py-2 text-right">
{editingId === obj.ID ? (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleSaveEdit(obj.ID)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 border border-green-300 rounded hover:bg-green-200 disabled:opacity-50"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300 rounded hover:bg-gray-200"
>
Abbrechen
</button>
</span>
) : (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setEditKategorie(obj.Kategorie); setError(''); }}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200 disabled:opacity-50"
>
Bearbeiten
</button>
<button
type="button"
onClick={() => handleDelete(obj.ID, obj.Name)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
Löschen
</button>
</span>
)}
</td>
</tr>
))}
{initialObjekte.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+17 -21
View File
@@ -2,26 +2,27 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { query } from '@/lib/db';
import * as phpdb from '@/lib/phpdb';
export interface BeoUser {
id: number;
kürzel: string | null;
name: string;
vorname: string | null;
role: string | null;
hasPw: boolean;
export type { BeoUser } from '@/lib/phpdb';
export interface ObjektRow {
ID: number;
Name: string;
LastUsed: string | null;
Kategorie: string;
}
export async function listUsers(): Promise<BeoUser[]> {
export async function listObjekte(): Promise<ObjektRow[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listObjekteAdmin();
}
const rows = await query(
'SELECT id, `kürzel`, name, vorname, role, (pw IS NOT NULL) AS hasPw FROM beos ORDER BY name, vorname',
[]
) as (Omit<BeoUser, 'hasPw'> & { hasPw: number })[];
return rows.map(r => ({ ...r, hasPw: r.hasPw === 1 }));
export async function listUsers(): Promise<phpdb.BeoUser[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listUsers();
}
export async function resetPassword(
@@ -33,16 +34,11 @@ export async function resetPassword(
return { error: 'Keine Berechtigung.' };
}
const idRaw = formData.get('id');
const id = Number(idRaw);
const id = Number(formData.get('id'));
if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' };
}
await query(
'UPDATE beos SET pw = NULL, MustChangePassword = 1 WHERE id = ?',
[id]
);
await phpdb.resetBeoPassword(id);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
}
+85 -43
View File
@@ -1,64 +1,106 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { listUsers } from './actions';
import { listUsers, listObjekte } from './actions';
import ResetButton from './ResetButton';
import ObjekteManager from './ObjekteManager';
export default async function AdminPage() {
type Tab = 'benutzer' | 'objekte';
export default async function AdminPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const session = await getSession();
if (!session) redirect('/login');
if (session.role === null || !session.role.includes('admin')) redirect('/');
const users = await listUsers();
const { tab: tabParam } = await searchParams;
const activeTab: Tab = tabParam === 'objekte' ? 'objekte' : 'benutzer';
const [users, objekte] = await Promise.all([
activeTab === 'benutzer' ? listUsers() : Promise.resolve([]),
activeTab === 'objekte' ? listObjekte() : Promise.resolve([]),
]);
const tabs: { id: Tab; label: string }[] = [
{ id: 'benutzer', label: 'Benutzerverwaltung' },
{ id: 'objekte', label: 'Objektverwaltung' },
];
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Benutzerverwaltung</h2>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.hasPw ? (
<span className="text-green-700">gesetzt</span>
) : (
<span className="text-amber-600 font-medium">Standard</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200">
{tabs.map(({ id, label }) => (
<Link
key={id}
href={`/admin?tab=${id}`}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === id
? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{label}
</Link>
))}
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
{/* Benutzerverwaltung */}
{activeTab === 'benutzer' && (
<>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.hasPw ? (
<span className="text-green-700">gesetzt</span>
) : (
<span className="text-amber-600 font-medium">Standard</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
</>
)}
{/* Objektverwaltung */}
{activeTab === 'objekte' && (
<ObjekteManager initialObjekte={objekte} />
)}
</main>
</div>
);
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
const DB_URL = (process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php')
.replace(/\?.*$/, '');
async function proxy(req: Request) {
const search = new URL(req.url).search;
const target = DB_URL + search;
const isReadOnly = req.method === 'GET' || req.method === 'HEAD';
const upstream = await fetch(target, {
method: req.method,
headers: { 'content-type': req.headers.get('content-type') ?? 'application/x-www-form-urlencoded' },
body: isReadOnly ? undefined : await req.arrayBuffer(),
cache: 'no-store',
});
const body = await upstream.arrayBuffer();
return new NextResponse(Buffer.from(body), {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
export const GET = proxy;
export const POST = proxy;
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
export async function POST() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
triggerBackup();
return NextResponse.json({ ok: true });
}
+2 -4
View File
@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
const rows = await query(
'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL AND FIND_IN_SET(\'guide\', role) > 0 ORDER BY name ASC'
) as { ID: number; Kuerzel: string; Name: string }[];
const rows = await phpdb.getBeos();
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/beos:', error);
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export type { FahrkostenRow } from '@/lib/phpdb';
export async function GET(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(req.url);
const ab = searchParams.get('ab');
if (!ab || !/^\d{4}-\d{2}-\d{2}$/.test(ab)) {
return NextResponse.json({ error: 'Parameter ab (YYYY-MM-DD) fehlt' }, { status: 400 });
}
try {
const rows = await phpdb.getFahrkosten(ab);
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/fahrkosten:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+22 -73
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch';
import { triggerBackup } from '@/lib/backup';
import * as phpdb from '@/lib/phpdb';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
@@ -11,66 +11,25 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const logbuchId = parseInt(id);
try {
// Zugriffskontrolle: Nur Ersteller oder Admin dürfen ändern
const existingRows = await query('SELECT created_by FROM logbuch WHERE ID = ?', [logbuchId]) as { created_by: number }[];
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const createdBy = existingRows[0].created_by;
const isCreator = createdBy === null || createdBy === session.beoId;
if (!isAdmin && !isCreator) {
return NextResponse.json({ error: 'Keine Berechtigung zum Ändern dieses Eintrags' }, { status: 403 });
}
const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await getPool().execute(
'UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, SonderName=?, Beginn=?, Ende=?, Besucher=?,' +
' Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? WHERE ID=?',
[
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende,
Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
logbuchId,
]
);
await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]);
await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]);
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await getPool().execute(
'INSERT INTO objekte (Name) VALUES (?)', [obj.Name]
) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId]
);
}
await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
Besucher: Besucher ?? 0,
beoIds: beoIds ?? [],
objekte: objekte ?? [],
Bemerkungen: Bemerkungen ?? null,
Wetter: Wetter ?? null,
});
triggerBackup();
return NextResponse.json({ ok: true });
} catch (error) {
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
if (error.message.includes('403')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
}
console.error('PUT /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
@@ -84,23 +43,13 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const logbuchId = parseInt(id);
try {
// Zugriffskontrolle: Nur Ersteller oder Admin dürfen löschen
const existingRows = await query('SELECT created_by FROM logbuch WHERE ID = ?', [logbuchId]) as { created_by: number }[];
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const createdBy = existingRows[0].created_by;
const isCreator = createdBy === null || createdBy === session.beoId;
if (!isAdmin && !isCreator) {
return NextResponse.json({ error: 'Keine Berechtigung zum Löschen dieses Eintrags' }, { status: 403 });
}
await query('DELETE FROM logbuch WHERE ID = ?', [logbuchId]);
await phpdb.deleteLogbuch(logbuchId, session.beoId, session.role ?? '');
return NextResponse.json({ ok: true });
} catch (error) {
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
if (error.message.includes('403')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
}
console.error('DELETE /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
+21 -84
View File
@@ -1,58 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch';
const LIST_SQL =
'SELECT' +
' l.ID, l.Kuppel, l.ArtFuehrung,' +
" DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn," +
" DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende," +
' l.Besucher, l.Bemerkungen, l.SonderName,' +
' l.WetterTemp, l.WetterFeuchte, l.WetterDruck,' +
' l.created_by, l.created_at,' +
' creator.kuerzel AS created_by_kuerzel,' +
" GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," +
" GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') AS Objekte" +
' FROM logbuch l' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) creator ON creator.id = l.created_by' +
' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' +
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
' WHERE l.Kuppel = ?' +
' GROUP BY l.ID' +
' ORDER BY l.Beginn DESC';
import { triggerBackup } from '@/lib/backup';
import * as phpdb from '@/lib/phpdb';
export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West';
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 100);
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500);
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
const month = searchParams.get('month') || '';
let listWhere = 'WHERE l.Kuppel = ?';
let countWhere = 'WHERE Kuppel = ?';
let params: (string | number | null)[] = [kuppel];
if (month && /^\d{4}-\d{2}$/.test(month)) {
const [y, m] = month.split('-').map(Number);
const start = `${y}-${String(m).padStart(2, '0')}-01`;
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
const end = `${nextY}-${String(nextM).padStart(2, '0')}-01`;
listWhere += ' AND l.Beginn >= ? AND l.Beginn < ?';
countWhere += ' AND Beginn >= ? AND Beginn < ?';
params = [kuppel, start, end];
}
const month = searchParams.get('month') || '';
const order = searchParams.get('order') === 'asc' ? 'asc' : 'desc';
const search = (searchParams.get('search') || '').trim();
try {
const [countRows, entries] = await Promise.all([
query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>,
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` LIMIT ${limit} OFFSET ${offset}`, params),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
const result = await phpdb.listLogbuch({ kuppel, limit, offset, month, search, order });
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -67,46 +32,18 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const pool = getPool();
const [result] = await pool.execute(
'INSERT INTO logbuch (Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' +
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende,
Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
session.beoId,
]
) as [{ insertId: number }, unknown];
const result = await phpdb.createLogbuch({
Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
Besucher: Besucher ?? 0,
beoIds: beoIds ?? [],
objekte: objekte ?? [],
Bemerkungen: Bemerkungen ?? null,
Wetter: Wetter ?? null,
created_by: session.beoId,
});
const logbuchId = result.insertId;
for (const beoId of (beoIds as number[]) || []) {
await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
}
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) {
objektId = existing[0].ID;
} else {
const [ins] = await pool.execute('INSERT INTO objekte (Name) VALUES (?)', [obj.Name]) as [{ insertId: number }, unknown];
objektId = ins.insertId;
}
}
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId]
);
}
return NextResponse.json({ id: logbuchId }, { status: 201 });
triggerBackup();
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('POST /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const VALID = ['stern', 'sonne', 'stern,sonne'];
const kat: string | undefined = VALID.includes(kategorie) ? kategorie : undefined;
const result = await phpdb.updateObjekt(numId, trimmed, kat);
return NextResponse.json(result);
} catch (error) {
console.error('PUT /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
await phpdb.deleteObjekt(numId);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DELETE /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+24 -4
View File
@@ -1,15 +1,35 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET() {
export async function GET(req: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try {
const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100');
const raw = req.nextUrl.searchParams.get('kategorie');
const kategorie = raw === 'sonne' ? 'sonne' : 'stern';
const rows = await phpdb.getObjekte(kategorie);
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const VALID = ['stern', 'sonne', 'stern,sonne'];
const kat: string = VALID.includes(kategorie) ? kategorie : 'stern';
const result = await phpdb.createObjekt(trimmed, kat);
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('POST /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
@@ -0,0 +1,18 @@
import { grafikProxy } from '../proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server';
const blockedHeaders = new Set([
'x-frame-options',
'content-security-policy',
'content-security-policy-report-only',
'frame-options',
'content-encoding',
'content-length',
'transfer-encoding',
]);
function buildProxyTarget(req: Request, slug?: string[]) {
const upstreamUrl = process.env.STATISTIK_GRAFIK_URL;
if (!upstreamUrl) {
throw new Error('STATISTIK_GRAFIK_URL is not configured');
}
const target = new URL(upstreamUrl);
const pathSuffix = slug?.filter(Boolean).join('/') || '';
if (pathSuffix) {
target.pathname = `${target.pathname.replace(/\/$/, '')}/${pathSuffix}`;
}
target.search = new URL(req.url).search;
return target.toString();
}
function buildAuthHeaders() {
const headers: Record<string, string> = {};
const user = process.env.STATISTIK_GRAFIK_USER;
const pass = process.env.STATISTIK_GRAFIK_PASS;
if (user && pass) {
const token = Buffer.from(`${user}:${pass}`).toString('base64');
headers.Authorization = `Basic ${token}`;
}
return headers;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function rewriteHtml(body: string) {
const proxyBase = '/api/statistik/grafik';
const upstreamOrigin = new URL(process.env.STATISTIK_GRAFIK_URL!).origin;
const escapedProxyBase = escapeRegExp(proxyBase);
const proxyBasePattern = escapedProxyBase.replace(/\//g, '\\/');
return body
.replace(/<meta[^>]*?(?:http-equiv|httpEquiv)\s*=\s*['"]?[^'">\s]+['"]?[^>]*>/ig, (m) => {
if (/x-frame-options/i.test(m)) return '';
if (/content-security-policy/i.test(m)) return '';
if (/frame-ancestors/i.test(m)) return '';
return m;
})
.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBase}/">`)
.replace(new RegExp(`(href|src|action)=(["'])(?!${proxyBasePattern}\\/)\/`, 'gi'), `$1=$2${proxyBase}/`)
.replace(new RegExp(`url\\((['"]?)(?!${proxyBasePattern}\\/)\/`, 'gi'), `url($1${proxyBase}/`)
.replace(new RegExp(escapeRegExp(upstreamOrigin), 'g'), proxyBase);
}
export async function grafikProxy(req: Request, slug?: string[]) {
let targetUrl: string;
try {
targetUrl = buildProxyTarget(req, slug);
} catch (error) {
return new NextResponse((error as Error).message, { status: 500 });
}
try {
const requestHeaders: Record<string, string> = {};
req.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'host' || lower === 'content-length') return;
requestHeaders[key] = value;
});
Object.assign(requestHeaders, buildAuthHeaders());
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer();
const upstream = await fetch(targetUrl, {
method: req.method,
headers: requestHeaders,
body,
cache: 'no-store',
});
const bodyBuf = await upstream.arrayBuffer();
const outHeaders: Record<string, string> = {};
upstream.headers.forEach((value, key) => {
if (!blockedHeaders.has(key.toLowerCase())) {
outHeaders[key] = value;
}
});
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
const isHtmlResponse = contentType.toLowerCase().includes('text/html');
// JS/CSS/font assets that come back as HTML are upstream errors (404, redirect pages).
// Return a 404 so the browser doesn't refuse to execute them as wrong MIME type.
const assetExtension = /\.(js|css|woff2?|ttf|eot|png|jpg|gif|svg|ico)(\?|$)/i;
const requestPath = new URL(req.url).pathname;
if (isHtmlResponse && slug?.length && assetExtension.test(requestPath)) {
return new NextResponse(null, { status: 404 });
}
if (isHtmlResponse) {
const text = Buffer.from(bodyBuf).toString('utf8');
const cleaned = rewriteHtml(text);
outHeaders['content-type'] = 'text/html; charset=utf-8';
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(cleaned, { status: upstream.status, headers: outHeaders });
}
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(Buffer.from(bodyBuf), { status: upstream.status, headers: outHeaders });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new NextResponse(`grafikProxy error: ${message}`, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { grafikProxy } from './proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
+3 -54
View File
@@ -1,68 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West';
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10);
try {
// 1) Monatliche Besucherzahlen nach ArtFuehrung
const monthlyRows = await query(
`SELECT
MONTH(Beginn) AS monat,
ArtFuehrung,
SUM(Besucher) AS besucher,
COUNT(*) AS anzahl
FROM logbuch
WHERE Kuppel = ? AND YEAR(Beginn) = ?
GROUP BY MONTH(Beginn), ArtFuehrung
ORDER BY monat, ArtFuehrung`,
[kuppel, year]
) as { monat: number; ArtFuehrung: string; besucher: number; anzahl: number }[];
// 2) Kumulierte Besucher im Jahr
const cumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { total: number | null }[];
// 3) Anzahl Führungstage (distinct Datum)
const tageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { tage: number }[];
// 4) Kumulierte Besucher über alle Kuppeln
const allCumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { total: number | null }[];
// 5) Führungstage über alle Kuppeln
const allTageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { tage: number }[];
return NextResponse.json({
monthly: monthlyRows.map((r) => ({
monat: Number(r.monat),
ArtFuehrung: r.ArtFuehrung,
besucher: Number(r.besucher),
anzahl: Number(r.anzahl),
})),
cumulative: Number(cumulativeRows[0]?.total ?? 0),
tage: Number(tageRows[0]?.tage ?? 0),
allCumulative: Number(allCumulativeRows[0]?.total ?? 0),
allTage: Number(allTageRows[0]?.tage ?? 0),
year,
kuppel,
});
const result = await phpdb.getStatistik(year);
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/statistik:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+2 -5
View File
@@ -3,7 +3,7 @@
import { redirect } from 'next/navigation';
import { getSession, createSession } from '@/lib/session';
import { hashPassword } from '@/lib/auth';
import { query } from '@/lib/db';
import { updateBeoPassword } from '@/lib/phpdb';
export async function changePassword(
_prevState: { error: string } | undefined,
@@ -28,10 +28,7 @@ export async function changePassword(
}
const hashed = await hashPassword(newPassword);
await query(
'UPDATE beos SET pw = ?, MustChangePassword = 0 WHERE id = ?',
[hashed, session.beoId]
);
await updateBeoPassword(session.beoId, hashed);
await createSession({
kuerzel: session.kuerzel,
+1 -1
View File
@@ -11,7 +11,7 @@ export default function ChangePasswordPage() {
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<h1 className="text-3xl font-bold mb-6">Logbuch Sternwarte Welzheim</h1>
<h1 className="text-3xl font-bold mb-6">Führungsbuch Sternwarte Welzheim</h1>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
+4
View File
@@ -33,4 +33,8 @@ body {
body {
background: white;
}
table {
font-size: 0.72rem !important;
width: 95% !important;
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Logbuch — Sternwarte Welzheim',
description: 'Logbuch für die Sternwarte Welzheim',
title: 'Führungsbuch — Sternwarte Welzheim',
description: 'Führungsbuch für die Sternwarte Welzheim',
};
export const viewport: Viewport = {
+1 -1
View File
@@ -17,7 +17,7 @@ export default function LoginPage() {
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
<h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
</div>
<div className="flex justify-center py-10">
Executable
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
# Tägliches MySQL-Backup für das Logbuch-Projekt.
#
# Verwendung:
# ./backup_db.sh
# DB_ROOT_PASS=... DB_NAME=... ./backup_db.sh
# ./backup_db.sh --days 14 --backup-dir /var/backups/logbuch
#
# Installiert keine Abhängigkeiten. Nutzt den laufenden Container mit dem Namen logbuch_mysql.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR_DEFAULT="$SCRIPT_DIR/DB_BACKUP"
CONTAINER_DEFAULT="logbuch_mysql"
KEEP_DAYS_DEFAULT=7
ROOT_PASS="${DB_ROOT_PASS:-${MYSQL_ROOT_PASSWORD:-}}"
DB_NAME="${DB_NAME:-${MYSQL_DATABASE:-}}"
BACKUP_DIR="$BACKUP_DIR_DEFAULT"
CONTAINER="$CONTAINER_DEFAULT"
KEEP_DAYS="$KEEP_DAYS_DEFAULT"
REMOTE=""
REMOTE_RETENTION=""
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--backup-dir DIR Backup-Verzeichnis (default: $BACKUP_DIR_DEFAULT)
--container NAME MySQL-Container-Name (default: $CONTAINER_DEFAULT)
--days N Aufbewahrungszeitraum in Tagen (default: $KEEP_DAYS_DEFAULT)
--db-name NAME Datenbankname (default: DB_NAME oder MYSQL_DATABASE env)
--root-pass PASS MySQL root Passwort (default: DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD env)
--remote user@host:/path Optional: kopiere Backup per scp auf Remote-Host
--remote-retention N Optional: Aufbewahrungstage auf Remote (default: same as --days)
-h, --help Diese Hilfe anzeigen
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backup-dir)
BACKUP_DIR="$2"
shift 2
;;
--container)
CONTAINER="$2"
shift 2
;;
--days)
KEEP_DAYS="$2"
shift 2
;;
--db-name)
DB_NAME="$2"
shift 2
;;
--root-pass)
ROOT_PASS="$2"
shift 2
;;
--remote)
REMOTE="$2"
shift 2
;;
--remote-retention)
REMOTE_RETENTION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unbekannte Option: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$ROOT_PASS" ]]; then
echo "Fehler: MySQL root Passwort ist nicht gesetzt. Setze DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD." >&2
exit 1
fi
if [[ -z "$DB_NAME" ]]; then
echo "Fehler: Datenbankname ist nicht gesetzt. Setze DB_NAME oder MYSQL_DATABASE." >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_$TIMESTAMP.sql.gz"
echo "Erstelle Backup: $BACKUP_FILE"
docker exec "$CONTAINER" mysqldump --single-transaction --quick --lock-tables=false \
--triggers --routines --events --set-gtid-purged=OFF -uroot -p"$ROOT_PASS" "$DB_NAME" | gzip > "$BACKUP_FILE"
if [[ ! -s "$BACKUP_FILE" ]]; then
echo "Fehler: Backup konnte nicht erstellt werden." >&2
exit 1
fi
echo "Backup erfolgreich erstellt: $BACKUP_FILE"
# Optional: upload to remote host (format: user@host:/path)
if [[ -n "$REMOTE" ]]; then
echo "Upload nach Remote: $REMOTE"
if [[ -z "$REMOTE_RETENTION" ]]; then
REMOTE_RETENTION="$KEEP_DAYS"
fi
if [[ "$REMOTE" == *":"* ]]; then
REMOTE_HOSTPART="${REMOTE%%:*}"
REMOTE_PATH="${REMOTE#*:}"
if [[ -z "$REMOTE_PATH" ]]; then
REMOTE_PATH="."
fi
# Verzeichnis auf Remote anlegen, dann Datei kopieren
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "mkdir -p \"$REMOTE_PATH\""
if ! scp -q "$BACKUP_FILE" "${REMOTE_HOSTPART}:${REMOTE_PATH}/"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
echo "Auf Remote $REMOTE_HOSTPART: entferne Backups älter als $REMOTE_RETENTION Tage in $REMOTE_PATH"
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "find \"$REMOTE_PATH\" -maxdepth 1 -type f -name '${DB_NAME}_*.sql.gz' -mtime +$REMOTE_RETENTION -print -delete" || true
fi
else
if ! scp -q "$BACKUP_FILE" "$REMOTE"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
fi
fi
fi
echo "Lösche Backups älter als $KEEP_DAYS Tage im Verzeichnis $BACKUP_DIR"
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${DB_NAME}_*.sql.gz" -mtime +"$KEEP_DAYS" -print -delete || true
echo "Fertig."
+2 -2
View File
@@ -1,5 +1,5 @@
Erstelle ein Logbuch für die Sternwarte in Welzheim.
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Logbuch. Alle 4 Logbücher sind identisch gebaut.
Erstelle ein Führungsbuch für die Sternwarte in Welzheim.
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Führungsbuch. Alle 4 Logbücher sind identisch gebaut.
+ Als Datenbank soll eine MYSQL verwendet werden
+ Als Sprache soll nextjs zum Einsatz kommen
+ Der Zugang zu der Webseite soll per User/Passwort gesichert werden
+241
View File
@@ -0,0 +1,241 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
interface Props {
value: string; // "YYYY-MM-DD"
onChange: (value: string) => void;
className?: string;
}
const MONTH_NAMES = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
const WEEKDAY_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function monthKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}
function monthLabel(ym: string): string {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
return `${MONTH_NAMES[m - 1]} ${y}`;
}
function shiftMonth(ym: string, delta: number): string {
const [ys, ms] = ym.split('-');
const d = new Date(Number(ys), Number(ms) - 1 + delta, 1);
return monthKey(d);
}
function parseISODate(v: string): Date | null {
if (!isValidDateString(v)) return null;
const [ys, ms, ds] = v.split('-');
return new Date(Number(ys), Number(ms) - 1, Number(ds));
}
function buildMonthGrid(ym: string): Array<number | null> {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
const first = new Date(y, m - 1, 1);
const days = new Date(y, m, 0).getDate();
const mondayStartOffset = (first.getDay() + 6) % 7;
const cells: Array<number | null> = Array(mondayStartOffset).fill(null);
for (let d = 1; d <= days; d += 1) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
function isValidDateString(v: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
const [ys, ms, ds] = v.split('-');
const y = Number(ys);
const m = Number(ms);
const d = Number(ds);
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
function normalize(v: string): string {
const digits = v.replace(/\D/g, '').slice(0, 8);
const y = digits.slice(0, 4);
const m = digits.slice(4, 6);
const d = digits.slice(6, 8);
if (digits.length <= 4) return y;
if (digits.length <= 6) return `${y}-${m}`;
return `${y}-${m}-${d}`;
}
export default function DateInput({ value, onChange, className = '' }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
const [open, setOpen] = useState(false);
const [viewMonth, setViewMonth] = useState(monthKey(new Date()));
const wrapperRef = useRef<HTMLDivElement>(null);
const selectedDate = useMemo(() => parseISODate(value), [value]);
const dayCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
useEffect(() => {
setLocal(value);
setError(false);
}, [value]);
useEffect(() => {
const date = parseISODate(value);
if (date) setViewMonth(monthKey(date));
}, [value]);
useEffect(() => {
function onDocMouseDown(ev: MouseEvent) {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(ev.target as Node)) setOpen(false);
}
function onDocKeyDown(ev: KeyboardEvent) {
if (ev.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onDocKeyDown);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onDocKeyDown);
};
}, []);
function handleChange(raw: string) {
setError(false);
setLocal(normalize(raw));
}
function handleBlur() {
if (local === '') {
setLocal(value);
setError(false);
return;
}
if (isValidDateString(local)) {
setError(false);
onChange(local);
return;
}
setError(true);
}
function selectDay(day: number) {
const [ys, ms] = viewMonth.split('-');
const iso = `${ys}-${ms}-${pad(day)}`;
setLocal(iso);
setError(false);
onChange(iso);
setOpen(false);
}
function isSelected(day: number): boolean {
if (!selectedDate) return false;
const [ys, ms] = viewMonth.split('-').map(Number);
return selectedDate.getFullYear() === ys && selectedDate.getMonth() === ms - 1 && selectedDate.getDate() === day;
}
return (
<div ref={wrapperRef} className={`relative ${className}`}>
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
value={local}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder="YYYY-MM-DD"
maxLength={10}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
}`}
/>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-xs text-gray-700 hover:bg-gray-50"
aria-label="Kalender oeffnen"
>
Kalender
</button>
</div>
{open && (
<div className="absolute left-0 top-full mt-1 z-20 w-64 border-2 border-gray-300 rounded-lg bg-white shadow-lg p-2">
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, -1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Vorheriger Monat"
>
{'<'}
</button>
<div className="text-sm font-medium">{monthLabel(viewMonth)}</div>
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, 1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Naechster Monat"
>
{'>'}
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAY_SHORT.map((wd) => (
<div key={wd} className="text-[11px] text-center text-gray-500 py-1">
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{dayCells.map((day, idx) => (
<button
key={`${viewMonth}-${idx}`}
type="button"
disabled={day === null}
onClick={() => day !== null && selectDay(day)}
className={`h-8 rounded text-sm ${
day === null
? 'text-transparent cursor-default'
: isSelected(day)
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-gray-100 text-gray-800'
}`}
>
{day ?? '-'}
</button>
))}
</div>
</div>
)}
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungueltiges Datum (YYYY-MM-DD)
</p>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import { useEffect, useState } from 'react';
interface FahrkostenRow {
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
const SATZ = Number(process.env.NEXT_PUBLIC_FAHRKOSTEN_SATZ ?? 15);
function defaultAb(): string {
const d = new Date();
return `${d.getFullYear()}-01-01`;
}
export default function Fahrkosten() {
const [ab, setAb] = useState(defaultAb);
const [rows, setRows] = useState<FahrkostenRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
fetch(`/api/fahrkosten?ab=${ab}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: FahrkostenRow[]) => { if (!cancelled) { setRows(d); setLoading(false); } })
.catch(() => { if (!cancelled) { setError('Fehler beim Laden.'); setLoading(false); } });
return () => { cancelled = true; };
}, [ab]);
const gesamt = rows ? rows.reduce((s, r) => s + r.Anzahl * SATZ, 0) : 0;
const thCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-left whitespace-nowrap text-gray-900';
const thNarrowCls = `${thCls} w-16`;
const tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const tdNarrowCls = `${tdCls} w-16`;
const tdNumCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums w-20 text-gray-900';
const abFormatted = new Date(ab + 'T00:00:00').toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
return (
<div className="space-y-4 print:pl-[1.5cm]">
<div className="flex items-center gap-3 print:hidden">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">Ab Datum</label>
<input
type="date"
value={ab}
onChange={(e) => setAb(e.target.value)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Druckkopf */}
<div className="hidden print:block mb-2">
<div className="text-lg font-bold">Sternwarte Welzheim Fahrkostenabrechnung</div>
<div className="text-sm text-gray-600">Führungen ab <strong>{abFormatted}</strong> · {SATZ} pro Führung</div>
<div className="text-xs text-gray-400 mt-0.5">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div>
{loading && <div className="text-gray-500 text-sm py-4">Lade...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && rows && (
<>
{rows.length === 0 ? (
<div className="text-gray-500 text-sm py-4">Keine Führungen in diesem Zeitraum gefunden.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className={thCls}>Name</th>
<th className={thNarrowCls}>Kürzel</th>
<th className={`${thNarrowCls} text-right`}>Führungen</th>
<th className={`${thCls} text-right`}>Fahrkosten</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.ID} className="hover:bg-gray-50 print:hover:bg-transparent">
<td className={tdCls}>{r.Name}</td>
<td className={tdNarrowCls}>{r.Kuerzel}</td>
<td className={tdNumCls}>{r.Anzahl}</td>
<td className={tdNumCls}>{(r.Anzahl * SATZ).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className={`${tdCls} font-semibold`} colSpan={2}>Gesamt</td>
<td className={`${tdNumCls} font-semibold`}>{rows.reduce((s, r) => s + r.Anzahl, 0)}</td>
<td className={`${tdNumCls} font-semibold`}>{gesamt.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
</tfoot>
</table>
</div>
)}
</>
)}
</div>
);
}
+52 -45
View File
@@ -20,10 +20,18 @@ function toLocalDatetimeValue(isoOrDatetime: string): string {
return isoOrDatetime.slice(0, 16);
}
function nowLocalDatetime(): string {
function todayDate(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function nowRounded5(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const total = now.getHours() * 60 + now.getMinutes();
const rounded = Math.ceil(total / 5) * 5;
return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`;
}
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
@@ -31,14 +39,15 @@ const SONNE_ART: ArtFuehrung = 'SonF';
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF');
const [beginn, setBeginn] = useState(nowLocalDatetime());
const [ende, setEnde] = useState(nowLocalDatetime());
const [beginn, setBeginn] = useState(todayDate());
const [ende, setEnde] = useState(todayDate() + 'T' + nowRounded5());
const [besucher, setBesucher] = useState<number | ''>('');
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState('');
const [sonderName, setSonderName] = useState('');
const [wetter, setWetter] = useState<Wetter>({ temp: 0, feuchte: 0, druck: 0 });
const [tempRaw, setTempRaw] = useState('0');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
@@ -68,6 +77,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.then((w: Wetter) => {
if (!editEntryRef.current) {
setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) });
setTempRaw(String(w.temp));
}
})
.catch(() => {});
@@ -82,16 +92,14 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBemerkungen(editEntry.Bemerkungen ?? '');
setSonderName(editEntry.SonderName ?? '');
if (editEntry.WetterTemp !== null) {
setWetter({
temp: editEntry.WetterTemp ?? 0,
feuchte: Math.round(editEntry.WetterFeuchte ?? 0),
druck: Math.round(editEntry.WetterDruck ?? 0),
});
const t = editEntry.WetterTemp ?? 0;
setWetter({ temp: t, feuchte: Math.round(editEntry.WetterFeuchte ?? 0), druck: Math.round(editEntry.WetterDruck ?? 0) });
setTempRaw(String(t));
}
} else {
setArtFuehrung('RF');
setBeginn(nowLocalDatetime());
setEnde(nowLocalDatetime());
setBeginn(todayDate());
setEnde(todayDate() + 'T' + nowRounded5());
setBesucher(0);
setBeos([currentUserBeo]);
setObjekte([]);
@@ -112,8 +120,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {});
}
if (editEntry && editEntry.Objekte) {
const names = editEntry.Objekte.split(', ').map((n) => n.trim());
fetch('/api/objekte')
const allNames = editEntry.Objekte.split(', ').map((n) => n.trim());
const isSonneEntry = editEntry.ArtFuehrung === SONNE_ART;
const names = isSonneEntry ? allNames.filter((n) => n.toLowerCase() !== 'sonne') : allNames;
const kat = isSonneEntry ? 'sonne' : 'stern';
fetch('/api/objekte?kategorie=' + kat)
.then((r) => r.json())
.then((all: { ID: number; Name: string }[]) => {
const result: SelectedObjekt[] = names.map((name) => {
@@ -126,12 +137,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
}
}, [editEntry]);
// Objekte-Vorauswahl je nach Art der Führung
// Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung
useEffect(() => {
if (artFuehrung === SONNE_ART) {
setObjekte([{ ID: null, Name: 'Sonne' }]);
} else if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setObjekte([]);
setObjekte([]);
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setBesucher('');
}
}, [artFuehrung]);
@@ -144,8 +154,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setError('Bitte Besucherzahl eingeben.');
return;
}
if (!beginn.slice(11, 16)) {
setError('Bitte Startzeit eingeben.');
return;
}
if (beginn === ende) {
setError('Die Zeite wurden nicht eingegeben');
setError('Start- und Endzeit sind identisch.');
return;
}
@@ -159,9 +173,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
Ende: ende,
Besucher: besucher === '' ? 0 : besucher,
beoIds: beos.map((b) => b.ID),
objekte: showObjekte ? objekte : [],
objekte: showObjekte ? (isSonne ? [{ ID: null, Name: 'Sonne' }, ...objekte] : objekte) : [],
Bemerkungen: bemerkungen,
Wetter: wetter,
Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 },
};
const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch';
@@ -177,8 +191,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setSuccess(true);
setTimeout(() => setSuccess(false), 5000);
if (!editEntry) {
setBeginn(nowLocalDatetime());
setEnde(nowLocalDatetime());
setBeginn(todayDate());
setEnde(todayDate() + 'T' + nowRounded5());
setBesucher(0);
setBeos([currentUserBeo]);
setObjekte([]);
@@ -192,7 +206,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
}
}
const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none';
const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none';
const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5';
return (
@@ -238,10 +252,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<label className={labelCls}>Startzeit</label>
<TimeInput
value={beginn.slice(11, 16)}
onChange={(t) => {
setBeginn(beginn.slice(0, 10) + 'T' + t);
setEnde(ende.slice(0, 10) + 'T' + t);
}}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
autoFocus
className="w-24"
/>
</div>
@@ -250,7 +262,6 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<TimeInput
value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
clearOnFocus
className="w-24"
/>
</div>
@@ -263,7 +274,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)}
min={0}
max={9999}
className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
)}
@@ -294,16 +305,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{showObjekte && (
<div>
<label className={labelCls}>Beobachtete Objekte</label>
{isSonne ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1.5 rounded-full">
Sonne
</span>
<span className="text-xs text-gray-500">(bei Sonnenführung fest vorgegeben)</span>
</div>
) : (
<ObjektSelector selected={objekte} onChange={setObjekte} />
)}
<ObjektSelector
selected={objekte}
onChange={setObjekte}
kategorie={isSonne ? 'sonne' : 'stern'}
fixedItems={isSonne ? [{ ID: null, Name: 'Sonne' }] : []}
/>
</div>
)}
@@ -317,7 +324,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={2}
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none resize-y"
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none resize-y"
placeholder="Freier Text (max. 500 Zeichen)"
/>
</div>
@@ -328,10 +335,10 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<div>
<label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label>
<input
type="number"
value={wetter.temp}
onChange={(e) => setWetter({ ...wetter, temp: parseFloat(e.target.value) || 0 })}
step="0.1"
type="text"
inputMode="decimal"
value={tempRaw}
onChange={(e) => setTempRaw(e.target.value)}
className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
+133 -54
View File
@@ -1,12 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
interface Props {
kuppel: Kuppel;
refreshKey: number;
onEdit: (entry: LogbuchEintrag) => void;
currentUserKuerzel: string;
isAdmin?: boolean;
limit?: number;
compact?: boolean;
}
@@ -50,27 +52,73 @@ function formatTime(dt: string): string {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, compact = false }: Props) {
export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKuerzel, isAdmin = false, limit = 10, compact = false }: Props) {
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [month, setMonth] = useState(compact ? '' : currentMonth());
const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState('');
const [deleteErr, setDeleteErr] = useState('');
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const printPending = useRef(false);
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]);
// Derived page: auto-resets to 0 when filter deps change — no separate setState-in-effect needed
const filterKey = `${kuppel}|${refreshKey}|${month}|${activeSearch}`;
const [pageState, setPageState] = useState({ page: 0, key: filterKey });
const page = pageState.key === filterKey ? pageState.page : 0;
// Fetch result tracked by paramsKey — loading/error are derived, not set synchronously in effect
const paramsKey = `${filterKey}|${limit}|${page}`;
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forParams: string } | null>(null);
const fetchError = fetchResult?.forParams === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
const loading = !fetchError && fetchResult?.forParams !== paramsKey;
const error = fetchError || deleteErr;
useEffect(() => {
setLoading(true);
const t = setTimeout(() => setActiveSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
let cancelled = false;
const offset = page * limit;
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
(month ? `&month=${encodeURIComponent(month)}` : '');
(activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : ''));
fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); })
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
}, [kuppel, refreshKey, limit, page, month]);
.then((data) => {
if (!cancelled) {
setEntries(data.entries);
setTotal(data.total);
setFetchResult({ ok: true, forParams: paramsKey });
setDeleteErr('');
}
})
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forParams: paramsKey }); });
return () => { cancelled = true; };
}, [kuppel, refreshKey, limit, page, month, activeSearch]);
useEffect(() => {
function onAfterPrint() { setPrintEntries(null); }
window.addEventListener('afterprint', onAfterPrint);
return () => window.removeEventListener('afterprint', onAfterPrint);
}, []);
useEffect(() => {
if (printPending.current && printEntries !== null) {
printPending.current = false;
window.print();
}
}, [printEntries]);
async function handlePrint() {
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=500&offset=0` +
(month ? `&month=${encodeURIComponent(month)}` : '') + '&order=asc';
const data = await fetch(url).then((r) => r.json());
printPending.current = true;
setPrintEntries(data.entries);
}
async function confirmDelete(id: number) {
try {
@@ -79,37 +127,63 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
setEntries((prev) => prev.filter((e) => e.ID !== id));
setTotal((t) => t - 1);
} catch {
setError('Fehler beim Löschen.');
setDeleteErr('Fehler beim Löschen.');
} finally {
setDeleteId(null);
}
}
const monthNav = !compact && (
const toolbar = !compact && (
<div className="flex items-center gap-2 mb-3 print:hidden">
<button
onClick={() => setMonth((m) => prevMonth(m))}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300"
></button>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<div
className="flex items-center gap-1 shrink-0"
style={{ visibility: activeSearch ? 'hidden' : 'visible' }}
>
<button
onClick={() => setMonth(currentMonth())}
className="text-sm text-blue-600 hover:underline"
>
Aktueller Monat
</button>
)}
onClick={() => setMonth((m) => prevMonth(m))}
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5]"
></button>
<input
type="month"
value={month}
max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5] disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<button
onClick={() => setMonth(currentMonth())}
className="text-sm text-blue-600 hover:underline ml-1"
>Aktueller Monat</button>
)}
</div>
<div className="relative flex-1 min-w-0 mx-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche in Bemerkungen, Objekte, BEOs…"
className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/>
{search ? (
<button
onClick={() => setSearch('')}
aria-label="Suche löschen"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm leading-none"
></button>
) : (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none text-sm">🔍</span>
)}
</div>
<button
onClick={handlePrint}
className="text-sm px-3 py-1.5 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black rounded-lg shrink-0"
>🖨 Drucken</button>
</div>
);
@@ -120,20 +194,21 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
);
const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs'
: 'px-3 py-2 border border-gray-200';
? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
: 'px-3 py-2 border border-gray-200 text-gray-900';
const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
: 'px-3 py-2 border border-gray-300';
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
: 'px-3 py-2 border border-gray-300 text-gray-900';
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
const displayEntries = printEntries ?? entries;
return (
<div>
{monthNav}
{toolbar}
{printHeader}
<div className="overflow-x-auto">
{loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && <div className="overflow-x-auto">
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
<thead>
<tr className="bg-gray-100 text-left">
@@ -156,13 +231,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
{displayEntries.length === 0 ? (
<tr>
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
Keine Einträge für {monthLabel(month)}.
{activeSearch ? `Keine Einträge für „${activeSearch}" gefunden.` : month ? `Keine Einträge für ${monthLabel(month)}.` : 'Keine Einträge vorhanden.'}
</td>
</tr>
) : entries.map((e) => (
) : displayEntries.map((e) => (
<tr key={e.ID} className="hover:bg-gray-50">
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.Beginn, compact)}</td>
{compact ? (
@@ -203,7 +278,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
{!compact && <td className={cell}>{e.Bemerkungen || ''}</td>}
{!compact && (
<td className={cell}>
{e.WetterTemp !== null && (
{e.WetterTemp !== null && !(parseFloat(String(e.WetterTemp)) === 0 && parseFloat(String(e.WetterFeuchte ?? 0)) === 0 && parseFloat(String(e.WetterDruck ?? 0)) === 0) && (
<div className="text-xs whitespace-nowrap">
<div>{e.WetterTemp} °C</div>
<div>{Math.round(e.WetterFeuchte ?? 0)} %</div>
@@ -213,27 +288,31 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
</td>
)}
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button>
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button>
{(isAdmin || e.BEOs?.split(', ').includes(currentUserKuerzel)) && (
<>
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button>
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>}
{!compact && total > limit && (
<div className="flex items-center justify-center gap-3 mt-3 print:hidden">
<button
onClick={() => setPage((p) => p - 1)}
onClick={() => setPageState({ page: page - 1, key: filterKey })}
disabled={page === 0}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
> Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button
onClick={() => setPage((p) => p + 1)}
onClick={() => setPageState({ page: page + 1, key: filterKey })}
disabled={(page + 1) * limit >= total}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
>Weiter </button>
</div>
)}
+72 -73
View File
@@ -6,22 +6,23 @@ import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
interface Props {
selected: SelectedObjekt[];
onChange: (objekte: SelectedObjekt[]) => void;
kategorie?: 'stern' | 'sonne';
fixedItems?: SelectedObjekt[];
}
export default function ObjektSelector({ selected, onChange }: Props) {
export default function ObjektSelector({ selected, onChange, kategorie = 'stern', fixedItems = [] }: Props) {
const [all, setAll] = useState<ObjektOption[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [newName, setNewName] = useState('');
const [showNewInput, setShowNewInput] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch('/api/objekte')
fetch('/api/objekte?kategorie=' + kategorie)
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
.then(setAll)
.catch(() => {});
}, []);
}, [kategorie]);
useEffect(() => {
function handleOutside(e: MouseEvent) {
@@ -33,38 +34,64 @@ export default function ObjektSelector({ selected, onChange }: Props) {
return () => document.removeEventListener('mousedown', handleOutside);
}, [dropdownOpen]);
const fixedNamesLower = new Set(fixedItems.map((o) => o.Name.toLowerCase()));
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
const available = all.filter(
(o) => !selectedNames.has(o.Name.toLowerCase()) && !fixedNamesLower.has(o.Name.toLowerCase())
);
const filtered = search
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
: available;
const searchTrimmed = search.trim();
const alreadySelected = searchTrimmed !== '' && (selectedNames.has(searchTrimmed.toLowerCase()) || fixedNamesLower.has(searchTrimmed.toLowerCase()));
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase());
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
function add(obj: ObjektOption) {
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
setSearch('');
setDropdownOpen(false);
inputRef.current?.focus();
}
function addNew() {
const name = newName.trim();
if (!name || selectedNames.has(name.toLowerCase())) return;
const existing = all.find((o) => o.Name.toLowerCase() === name.toLowerCase());
function addNew(name: string) {
const trimmed = name.trim();
if (!trimmed || selectedNames.has(trimmed.toLowerCase()) || fixedNamesLower.has(trimmed.toLowerCase())) return;
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase());
if (existing) {
onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
} else {
onChange([...selected, { ID: null, Name: name }]);
onChange([...selected, { ID: null, Name: trimmed }]);
}
setNewName('');
setShowNewInput(false);
setSearch('');
inputRef.current?.focus();
}
function remove(name: string) {
onChange(selected.filter((o) => o.Name !== name));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Enter') return;
e.preventDefault();
if (filtered.length === 1 && !showAddNew) {
add(filtered[0]);
} else if (filtered.length === 0 && searchTrimmed) {
addNew(searchTrimmed);
}
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{fixedItems.map((o) => (
<span
key={'fixed-' + o.Name}
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full"
>
{o.Name}
</span>
))}
{selected.map((o) => (
<span
key={o.Name}
@@ -83,69 +110,41 @@ export default function ObjektSelector({ selected, onChange }: Props) {
))}
</div>
<div className="flex gap-2">
{available.length > 0 && (
<div ref={wrapperRef} className="relative flex-1">
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
placeholder="Objekt suchen..."
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
{dropdownOpen && filtered.length > 0 && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filtered.map((o) => (
<button
key={o.ID}
type="button"
onClick={() => add(o)}
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
>
{o.Name}
</button>
))}
</div>
<div ref={wrapperRef} className="relative">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Objekt suchen oder neu eingeben..."
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
{dropdownOpen && (filtered.length > 0 || showAddNew) && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filtered.map((o) => (
<button
key={o.ID}
type="button"
onClick={() => add(o)}
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
>
{o.Name}
</button>
))}
{showAddNew && (
<button
type="button"
onClick={() => addNew(searchTrimmed)}
className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium"
>
+ &bdquo;{searchTrimmed}&ldquo; hinzufügen
</button>
)}
</div>
)}
<button
type="button"
onClick={() => setShowNewInput((v) => !v)}
className="px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap"
>
+ Neu
</button>
</div>
{showNewInput && (
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
placeholder="Objektname eingeben"
className="flex-1 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
autoFocus
/>
<button
type="button"
onClick={addNew}
className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
>
OK
</button>
<button
type="button"
onClick={() => { setShowNewInput(false); setNewName(''); }}
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
>
</button>
</div>
)}
</div>
);
}
+104 -103
View File
@@ -1,24 +1,29 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { Kuppel } from '@/types/logbuch';
import { artLabel } from '@/types/logbuch';
import { useEffect, useState } from 'react';
interface MonthlyRow {
interface MonthRow {
monat: number;
ArtFuehrung: string;
besucher: number;
anzahl: number;
tageFuehrungen: number;
tageBeob: number;
tageTD: number;
tageSonst: number;
tageBEOS: number;
tagesToT: number;
tageGesamt: number;
besucherRF: number;
besucherSF: number;
besucherSonF: number;
besucherPrF: number;
besucherToT: number;
besucherGesamt: number;
}
interface StatsData {
monthly: MonthlyRow[];
monthly: MonthRow[];
cumulative: number;
tage: number;
allCumulative: number;
allTage: number;
year: number;
kuppel: Kuppel;
}
const MONATE = [
@@ -26,69 +31,44 @@ const MONATE = [
'Juli','August','September','Oktober','November','Dezember',
];
interface Props {
kuppel: Kuppel;
function n(v: number) {
return v > 0 ? v.toLocaleString('de-DE') : '';
}
export default function Statistik({ kuppel }: Props) {
export default function Statistik() {
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<StatsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [fetchError, setFetchError] = useState<number | null>(null);
const error = fetchError === year ? 'Fehler beim Laden der Statistik.' : '';
const loading = !error && (!data || data.year !== year);
useEffect(() => {
setLoading(true);
setError('');
fetch(`/api/statistik?kuppel=${encodeURIComponent(kuppel)}&year=${year}`)
let cancelled = false;
fetch(`/api/statistik?year=${year}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: StatsData) => { setData(d); setLoading(false); })
.catch(() => { setError('Fehler beim Laden der Statistik.'); setLoading(false); });
}, [kuppel, year]);
const { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal } = useMemo(() => {
if (!data) {
return { arten: [] as string[], matrix: [] as (number | null)[][], monatTotal: [] as number[], artTotal: [] as number[], grandTotal: 0, anzahlTotal: [] as number[] };
}
const artenSet = new Set<string>();
data.monthly.forEach((r) => artenSet.add(r.ArtFuehrung));
const arten = Array.from(artenSet).sort();
const matrix: (number | null)[][] = [];
const monatTotal: number[] = [];
const anzahlTotal: number[] = [];
const artTotal: number[] = new Array(arten.length).fill(0);
let grandTotal = 0;
for (let m = 1; m <= 12; m++) {
const row: (number | null)[] = [];
let mSum = 0;
let aSum = 0;
arten.forEach((art, idx) => {
const found = data.monthly.find((r) => r.monat === m && r.ArtFuehrung === art);
const val = found ? found.besucher : null;
row.push(val);
if (val !== null) {
mSum += val;
artTotal[idx] += val;
aSum += found!.anzahl;
}
});
matrix.push(row);
monatTotal.push(mSum);
anzahlTotal.push(aSum);
grandTotal += mSum;
}
return { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal };
}, [data]);
.then((d: StatsData) => { if (!cancelled) { setData(d); setFetchError(null); } })
.catch(() => { if (!cancelled) setFetchError(year); });
return () => { cancelled = true; };
}, [year]);
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Statistik...</div>;
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
const headCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 whitespace-nowrap';
const cellCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums';
const labelCls = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap';
const rows = data!.monthly;
function col(key: keyof MonthRow): number {
return rows.reduce((s, r) => s + (r[key] as number), 0);
}
const thTop = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-center text-gray-900';
const thSub = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const thDiv = 'px-3 py-2 border border-gray-300 border-l-4 border-l-gray-400 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const td = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums text-gray-900';
const tdDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums text-gray-900';
const tdL = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap text-gray-900';
const tdSum = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
const tdSumDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
return (
<div className="space-y-4">
@@ -100,7 +80,7 @@ export default function Statistik({ kuppel }: Props) {
onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
min={2000}
max={2100}
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
@@ -108,62 +88,83 @@ export default function Statistik({ kuppel }: Props) {
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className={headCls}>Monat</th>
<th className={headCls}>Führungen</th>
{arten.map((art) => (
<th key={art} className={headCls}>{artLabel(art as any) || art}</th>
))}
<th className={headCls}>Gesamt</th>
<th className={thTop} rowSpan={2}>Monat</th>
<th className={thTop} colSpan={6}>Besucher</th>
<th className={`${thTop} border-l-4 border-l-gray-400`} colSpan={7}>Anzahl</th>
</tr>
<tr>
<th className={thSub}>RF</th>
<th className={thSub}>SF</th>
<th className={thSub}>SonF</th>
<th className={thSub}>PrF</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
<th className={thDiv}>Führ.</th>
<th className={thSub}>Beob.</th>
<th className={thSub}>TD</th>
<th className={thSub}>Sonst.</th>
<th className={thSub}>BEOS</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
</tr>
</thead>
<tbody>
{MONATE.map((name, idx) => {
const mSum = monatTotal[idx];
const aSum = anzahlTotal[idx];
const m = idx + 1;
const r = rows.find((row) => row.monat === m);
const hasData = r && r.tageGesamt > 0;
const cls = hasData ? '' : 'text-gray-400';
return (
<tr key={name} className={mSum > 0 ? '' : 'text-gray-400'}>
<td className={labelCls}>{name}</td>
<td className={cellCls}>{aSum > 0 ? aSum : ''}</td>
{arten.map((_, aIdx) => {
const val = matrix[idx][aIdx];
return (
<td key={aIdx} className={cellCls}>
{val !== null && val > 0 ? val.toLocaleString('de-DE') : ''}
</td>
);
})}
<td className={`${cellCls} font-semibold`}>{mSum > 0 ? mSum.toLocaleString('de-DE') : ''}</td>
<tr key={name} className={cls}>
<td className={tdL}>{name}</td>
<td className={td}>{r ? n(r.besucherRF) : ''}</td>
<td className={td}>{r ? n(r.besucherSF) : ''}</td>
<td className={td}>{r ? n(r.besucherSonF) : ''}</td>
<td className={td}>{r ? n(r.besucherPrF) : ''}</td>
<td className={td}>{r ? n(r.besucherToT) : ''}</td>
<td className={`${td} font-semibold`}>{r ? n(r.besucherGesamt) : ''}</td>
<td className={`${tdDiv} font-semibold`}>{r ? n(r.tageFuehrungen) : ''}</td>
<td className={td}>{r ? n(r.tageBeob) : ''}</td>
<td className={td}>{r ? n(r.tageTD) : ''}</td>
<td className={td}>{r ? n(r.tageSonst) : ''}</td>
<td className={td}>{r ? n(r.tageBEOS) : ''}</td>
<td className={td}>{r ? n(r.tagesToT) : ''}</td>
<td className={`${td} font-semibold`}>{r ? n(r.tageGesamt) : ''}</td>
</tr>
);
})}
<tr className="bg-gray-50 font-semibold">
<td className={labelCls}>Summe</td>
<td className={cellCls}>{anzahlTotal.reduce((s, v) => s + v, 0) > 0 ? anzahlTotal.reduce((s, v) => s + v, 0).toLocaleString('de-DE') : ''}</td>
{artTotal.map((t, i) => (
<td key={i} className={cellCls}>{t > 0 ? t.toLocaleString('de-DE') : ''}</td>
))}
<td className={cellCls}>{grandTotal > 0 ? grandTotal.toLocaleString('de-DE') : ''}</td>
<tr>
<td className={tdL + ' font-semibold'}>Summe</td>
<td className={tdSum}>{n(col('besucherRF'))}</td>
<td className={tdSum}>{n(col('besucherSF'))}</td>
<td className={tdSum}>{n(col('besucherSonF'))}</td>
<td className={tdSum}>{n(col('besucherPrF'))}</td>
<td className={tdSum}>{n(col('besucherToT'))}</td>
<td className={tdSum}>{n(col('besucherGesamt'))}</td>
<td className={tdSumDiv}>{n(col('tageFuehrungen'))}</td>
<td className={tdSum}>{n(col('tageBeob'))}</td>
<td className={tdSum}>{n(col('tageTD'))}</td>
<td className={tdSum}>{n(col('tageSonst'))}</td>
<td className={tdSum}>{n(col('tageBEOS'))}</td>
<td className={tdSum}>{n(col('tagesToT'))}</td>
<td className={tdSum}>{n(col('tageGesamt'))}</td>
</tr>
</tbody>
</table>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full">
<div className="grid grid-cols-3 gap-4 w-full max-w-2xl">
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Kumulierte Besucher {year} ({data?.kuppel})</div>
<div className="text-2xl font-bold">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
<div className="text-xs text-gray-500 mb-1">Besucher {year}</div>
<div className="text-2xl font-bold text-gray-900">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
</div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Führungstage {year} ({data?.kuppel})</div>
<div className="text-2xl font-bold">{data?.tage ?? 0}</div>
<div className="text-xs text-gray-500 mb-1">Alle Events {year}</div>
<div className="text-2xl font-bold text-gray-900">{data?.tage ?? 0}</div>
</div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50">
<div className="text-xs text-green-700 mb-1">Kumulierte Besucher {year} (Sternwarte gesamt)</div>
<div className="text-2xl font-bold text-green-800">{data?.allCumulative.toLocaleString('de-DE') ?? 0}</div>
</div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50">
<div className="text-xs text-green-700 mb-1">Führungstage {year} (Sternwarte gesamt)</div>
<div className="text-2xl font-bold text-green-800">{data?.allTage ?? 0}</div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Führungen {year}</div>
<div className="text-2xl font-bold text-gray-900">{col('tageFuehrungen').toLocaleString('de-DE')}</div>
</div>
</div>
</div>
+6 -3
View File
@@ -7,6 +7,7 @@ interface Props {
onChange: (value: string) => void;
className?: string;
clearOnFocus?: boolean;
autoFocus?: boolean;
}
function isValid(t: string): boolean {
@@ -20,7 +21,7 @@ function normalize(t: string): string {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false }: Props) {
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false, autoFocus = false }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
@@ -52,8 +53,9 @@ export default function TimeInput({ value, onChange, className = '', clearOnFocu
setError(false);
return;
}
if (isValid(local)) {
const norm = normalize(local);
const expanded = /^\d{1,2}:?$/.test(local) ? local.replace(/:$/, '') + ':00' : local;
if (isValid(expanded)) {
const norm = normalize(expanded);
setLocal(norm);
setError(false);
onChange(norm);
@@ -71,6 +73,7 @@ export default function TimeInput({ value, onChange, className = '', clearOnFocu
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autoFocus}
placeholder="HH:MM"
maxLength={5}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
+2 -68
View File
@@ -1,79 +1,14 @@
services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
ports:
- "127.0.0.1:3336:3306"
networks:
- proxy
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
- -uroot
- -p${DB_ROOT_PASS}
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
depends_on:
logbuch_mysql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.logbuch-pma.entrypoints=http
- traefik.http.routers.logbuch-pma.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.middlewares.logbuch-pma-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.logbuch-pma.middlewares=logbuch-pma-https-redirect
- traefik.http.routers.logbuch-pma-secure.entrypoints=https
- traefik.http.routers.logbuch-pma-secure.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.routers.logbuch-pma-secure.tls=true
- traefik.http.routers.logbuch-pma-secure.middlewares=logbuch-pma-slash,logbuch-pma-strip
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.regex=^https://logbuch\.fuerst-stuttgart\.de/myadmin$$
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.replacement=https://logbuch.fuerst-stuttgart.de/myadmin/
- traefik.http.middlewares.logbuch-pma-strip.stripprefix.prefixes=/myadmin
- traefik.http.routers.logbuch-pma-secure.service=logbuch-pma
- traefik.http.services.logbuch-pma.loadbalancer.server.port=80
networks:
- proxy
logbuch_app:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
environment:
DB_HOST: logbuch_mysql
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
DB_PORT: 3306
PHP_DB_URL: ${PHP_DB_URL}
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
ports:
- 127.0.0.1:${APP_PORT:-3000}:3000
depends_on:
logbuch_mysql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.logbuch.entrypoints=http
@@ -88,6 +23,7 @@ services:
networks:
- proxy
- gitea-internal
networks:
proxy:
name: dockge_default
@@ -95,5 +31,3 @@ networks:
gitea-internal:
name: gitea_gitea-internal
external: true
volumes:
db_data: null
+6 -49
View File
@@ -1,64 +1,21 @@
services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
networks:
- logbuch_net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASS}"]
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
ports:
- "127.0.0.1:${PMA_PORT:-8080}:80"
depends_on:
logbuch_mysql:
condition: service_healthy
networks:
- logbuch_net
logbuch_app:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
env_file: .env
environment:
DB_HOST: logbuch_mysql
DB_PORT: 3306
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
BACKUP_SSH_KEY_PATH: /run/secrets/backup_ssh_key
volumes:
- ${BACKUP_SSH_KEY_FILE:-/dev/null}:/run/secrets/backup_ssh_key:ro
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on:
logbuch_mysql:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- logbuch_net
networks:
logbuch_net:
driver: bridge
volumes:
db_data:
+5 -27
View File
@@ -1,34 +1,13 @@
import bcrypt from 'bcryptjs';
import { query } from './db';
import { getBeoByKuerzel, getBeoByName } from './phpdb';
export interface Beo {
id: number;
name: string;
vorname: string | null;
kürzel: string | null;
pw: string | null;
MustChangePassword: number;
role: string | null;
}
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE `kürzel` = ?',
[kuerzel]
) as Beo[];
return rows[0] ?? null;
}
export type { Beo } from './phpdb';
import type { Beo } from './phpdb';
export async function getBeoByLogin(login: string): Promise<Beo | null> {
// First try exact Kürzel match, then case-insensitive Nachname match
const byKuerzel = await getBeoByKuerzel(login);
if (byKuerzel) return byKuerzel;
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE LOWER(name) = LOWER(?)',
[login]
) as Beo[];
return rows[0] ?? null;
return getBeoByName(login);
}
export async function verifyCredentials(
@@ -41,8 +20,7 @@ export async function verifyCredentials(
if (!beo.pw) {
const defaultPw = process.env.DEFAULT_PASSWORD;
if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!');
const valid = password === defaultPw;
return { beo, valid };
return { beo, valid: password === defaultPw };
}
const valid = await bcrypt.compare(password, beo.pw);
+123
View File
@@ -0,0 +1,123 @@
import { createWriteStream, mkdirSync, unlinkSync } from 'fs';
import { createGzip } from 'zlib';
import { join } from 'path';
import { spawn } from 'child_process';
import { getBackupData } from './phpdb';
export function triggerBackup(): void {
setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e)));
}
async function dumpToFile(filePath: string): Promise<void> {
const { tables } = await getBackupData();
const gzip = createGzip();
const file = createWriteStream(filePath);
gzip.pipe(file);
const write = (s: string) => new Promise<void>((res, rej) =>
gzip.write(s, (e) => e ? rej(e) : res())
);
const now = new Date().toISOString();
await write(`-- Führungsbuch Backup ${now}\n-- Logbuch-Tabellen\n\nSET FOREIGN_KEY_CHECKS=0;\n\n`);
for (const { name, createSql, rows } of tables) {
await write(`DROP TABLE IF EXISTS \`${name}\`;\n`);
await write(`${createSql};\n\n`);
if (rows.length > 0) {
const cols = Object.keys(rows[0]).map((c) => `\`${c}\``).join(', ');
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
const batch = rows.slice(i, i + batchSize);
const values = batch.map((row) =>
'(' + Object.values(row).map((v) => {
if (v === null) return 'NULL';
if (typeof v === 'number') return String(v);
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}).join(', ') + ')'
).join(',\n ');
await write(`INSERT INTO \`${name}\` (${cols}) VALUES\n ${values};\n`);
}
await write('\n');
}
}
await write('SET FOREIGN_KEY_CHECKS=1;\n');
await new Promise<void>((resolve, reject) => {
gzip.end();
file.on('close', resolve);
file.on('error', reject);
});
}
async function runBackup(): Promise<void> {
const sshUrl = process.env.BACKUP_SSH_URL || '';
const localDir = process.env.BACKUP_LOCAL_DIR || '/tmp/logbuch-backup';
const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19);
const filename = `sternwarte_${ts}.sql.gz`;
mkdirSync(localDir, { recursive: true });
const localPath = join(localDir, filename);
await dumpToFile(localPath);
console.log(`[backup] Dump geschrieben: ${localPath}`);
try {
if (!sshUrl) return;
const match = sshUrl.match(/^([^:]+):(.+)$/);
if (!match) {
console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben');
return;
}
const [, sshHost, remotePath] = match;
const rawKeyPath = process.env.BACKUP_SSH_KEY_PATH || '';
const keyPath = rawKeyPath.startsWith('~')
? rawKeyPath.replace('~', process.env.HOME || '/root')
: rawKeyPath;
const sshOpts = [
...(keyPath ? ['-i', keyPath] : []),
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15',
];
await new Promise<void>((resolve, reject) => {
const ssh = spawn('ssh', [...sshOpts, sshHost, `mkdir -p ${remotePath}`]);
ssh.on('error', reject);
ssh.on('close', (code) => code === 0 ? resolve() : reject(new Error(`mkdir -p exit ${code}`)));
});
await new Promise<void>((resolve, reject) => {
const scp = spawn('scp', [...sshOpts, localPath, `${sshHost}:${remotePath}/${filename}`]);
let scpErr = '';
scp.stderr.on('data', (d: Buffer) => { scpErr += d.toString(); });
scp.on('error', reject);
scp.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`))
);
});
console.log(`[backup] ${filename}${sshHost}:${remotePath}`);
await new Promise<void>((resolve) => {
const ssh = spawn('ssh', [
...sshOpts, sshHost,
`find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`,
]);
ssh.on('error', (e) => { console.error('[backup] Cleanup spawn-Fehler:', e.message); resolve(); });
ssh.on('close', (code) => {
if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')');
resolve();
});
});
} finally {
try { unlinkSync(localPath); } catch { /* bereits gelöscht oder nie angelegt */ }
}
}
-29
View File
@@ -1,29 +0,0 @@
import mysql from 'mysql2/promise';
import type { QueryResult } from 'mysql2/promise';
const dbConfig = {
host: process.env.DB_HOST || 'mydbase_mysql',
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME || 'logbuch',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
};
let pool: mysql.Pool | null = null;
export function getPool() {
if (!pool) {
pool = mysql.createPool(dbConfig);
}
return pool;
}
export async function query(sql: string, params?: (string | number | null)[]): Promise<QueryResult> {
const p = getPool();
const [rows] = await p.execute(sql, params || []);
return rows as QueryResult;
}
+185
View File
@@ -0,0 +1,185 @@
import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, Wetter } from '@/types/logbuch';
const PHP_DB_URL = process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php';
async function call<T>(cmd: string, params: object = {}): Promise<T> {
const res = await fetch(PHP_DB_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd, ...params }),
cache: 'no-store',
});
if (!res.ok) {
let detail = '';
try { detail = await res.text(); } catch { /* ignore */ }
throw new Error(`DB4js ${cmd} HTTP ${res.status}: ${detail}`);
}
const json: unknown = await res.json();
if (json && typeof json === 'object' && 'error' in json) {
throw new Error(`DB4js ${cmd}: ${(json as { error: string }).error}`);
}
return json as T;
}
// ---- Auth / Benutzer ----
export interface Beo {
id: number;
name: string;
vorname: string | null;
'kürzel': string | null;
pw: string | null;
MustChangePassword: number;
role: string | null;
}
export interface BeoUser {
id: number;
'kürzel': string | null;
name: string;
vorname: string | null;
role: string | null;
hasPw: boolean;
}
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const r = await call<{ beo: Beo | null }>('LB_AUTH_KUERZEL', { kuerzel });
return r.beo;
}
export async function getBeoByName(name: string): Promise<Beo | null> {
const r = await call<{ beo: Beo | null }>('LB_AUTH_NAME', { name });
return r.beo;
}
export async function updateBeoPassword(id: number, pwHash: string): Promise<void> {
await call('LB_UPDATE_PW', { id, pw: pwHash });
}
export async function resetBeoPassword(id: number): Promise<void> {
await call('LB_RESET_PW', { id });
}
export async function listUsers(): Promise<BeoUser[]> {
return call<BeoUser[]>('LB_LIST_USERS');
}
// ---- Logbuch ----
export interface ListLogbuchParams {
kuppel?: string;
limit?: number;
offset?: number;
month?: string;
search?: string;
order?: string;
}
export async function listLogbuch(
params: ListLogbuchParams
): Promise<{ entries: LogbuchEintrag[]; total: number }> {
return call('LB_LIST_LOGBUCH', params);
}
export interface CreateLogbuchData {
Kuppel: string;
ArtFuehrung: string;
SonderName?: string | null;
Beginn: string;
Ende: string;
Besucher?: number;
beoIds?: number[];
objekte?: SelectedObjekt[];
Bemerkungen?: string | null;
Wetter?: Partial<Wetter> | null;
created_by: number;
}
export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number }> {
return call('LB_CREATE_LOGBUCH', data);
}
export async function updateLogbuch(
id: number,
userId: number,
userRole: string,
data: Omit<CreateLogbuchData, 'created_by'>
): Promise<void> {
await call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data });
}
export async function deleteLogbuch(
id: number,
userId: number,
userRole: string
): Promise<void> {
await call('LB_DELETE_LOGBUCH', { id, user_id: userId, user_role: userRole });
}
// ---- BEOs & Objekte ----
export async function getBeos(): Promise<BeoOption[]> {
return call('LB_GET_BEOS');
}
export async function getObjekte(kategorie: 'stern' | 'sonne' = 'stern'): Promise<ObjektOption[]> {
return call('LB_GET_OBJEKTE', { kategorie });
}
export async function createObjekt(name: string, kategorie: string = 'stern'): Promise<ObjektOption> {
return call('LB_CREATE_OBJEKT', { name, kategorie });
}
export async function updateObjekt(id: number, name: string, kategorie?: string): Promise<ObjektOption> {
return call('LB_UPDATE_OBJEKT', { id, name, ...(kategorie ? { kategorie } : {}) });
}
export async function deleteObjekt(id: number): Promise<void> {
await call('LB_DELETE_OBJEKT', { id });
}
export async function listObjekteAdmin(): Promise<{ ID: number; Name: string; LastUsed: string | null; Kategorie: string }[]> {
return call('LB_LIST_OBJEKTE_ADMIN');
}
// ---- Auswertungen ----
export interface FahrkostenRow {
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
export async function getFahrkosten(ab: string): Promise<FahrkostenRow[]> {
return call('LB_FAHRKOSTEN', { ab });
}
export interface StatistikResult {
monthly: {
monat: number;
tageFuehrungen: number; tageBeob: number; tageTD: number;
tageSonst: number; tageBEOS: number; tagesToT: number; tageGesamt: number;
besucherRF: number; besucherSF: number; besucherSonF: number;
besucherPrF: number; besucherToT: number; besucherGesamt: number;
}[];
cumulative: number;
tage: number;
year: number;
}
export async function getStatistik(year: number): Promise<StatistikResult> {
return call('LB_STATISTIK', { year });
}
// ---- Backup ----
export interface BackupTable {
name: string;
createSql: string;
rows: Record<string, string | number | null>[];
}
export async function getBackupData(): Promise<{ tables: BackupTable[] }> {
return call('LB_BACKUP_DATA');
}
+6 -6
View File
@@ -4,11 +4,11 @@ import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 60 * 60 * 1000;
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) {
throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
function getKey(): Uint8Array {
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
return new TextEncoder().encode(secretKey);
}
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
kuerzel: string;
@@ -25,12 +25,12 @@ async function encrypt(payload: SessionData): Promise<string> {
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
.sign(getKey());
}
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData;
} catch {
return null;
+1 -1
View File
@@ -7,7 +7,6 @@ const nextConfig: NextConfig = {
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
@@ -19,6 +18,7 @@ const nextConfig: NextConfig = {
},
];
},
};
export default nextConfig;
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "logbuch",
"version": "1.7.0",
"version": "1.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "logbuch",
"version": "1.7.0",
"version": "1.7.5",
"dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
@@ -3298,6 +3298,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "logbuch",
"version": "1.7.0",
"version": "1.10.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -11,8 +11,7 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
"mysql2": "^3.22.3",
"next": "16.1.6",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
+16 -2
View File
@@ -3,7 +3,8 @@ import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session';
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) {
@@ -13,6 +14,11 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
// Allow the statistik grafik proxy route to be called without an app session cookie
if (pathname.startsWith('/api/statistik/grafik')) {
return NextResponse.next();
}
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
@@ -31,7 +37,15 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
if (pathname === '/api/statistik/grafik') {
return NextResponse.next();
}
return NextResponse.next({
headers: {
'X-Frame-Options': 'DENY',
},
});
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
+349
View File
@@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.65;
color: #1a1a2e;
background: #f4f6fb;
}
.page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff;
padding: 2.5rem 2rem 2rem;
border-radius: 12px;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
gap: 1.2rem;
}
header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
nav.toc {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.4rem 1.8rem;
margin-bottom: 2.5rem;
}
nav.toc h2 {
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
}
nav.toc ol {
list-style: none; counter-reset: toc;
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
}
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before {
content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-weight: 700; min-width: 1.4rem;
}
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; }
section {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.8rem 2rem;
margin-bottom: 1.5rem;
}
h2.section-title {
font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7;
display: flex; align-items: center; gap: 0.6rem;
}
h2.section-title .num {
background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
width: 1.6rem; height: 1.6rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
h3 {
font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
}
h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; }
ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
th {
background: #eef2fa; color: #2e4e8a; font-weight: 700;
text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
}
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; }
code {
background: #eef2fa; color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
}
.callout {
background: #eef6fb; border-left: 4px solid #85b7d7;
border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
margin: 0.9rem 0; font-size: 0.9rem;
}
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; }
.back-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
text-decoration: none; cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; }
}
@media print {
body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; }
header { border-radius: 0; background: #1a2d5a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
section { border: 1px solid #ccc; break-inside: avoid; }
nav.toc { break-after: page; }
a { color: inherit; text-decoration: none; }
.back-btn { display: none; }
}
</style>
</head>
<body>
<div class="page">
<a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header>
<div class="star"></div>
<div>
<h1>Bedienungsanleitung</h1>
<p>Führungsbuch Sternwarte Welzheim</p>
</div>
</header>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
<ol>
<li><a href="#s1">Anmelden</a></li>
<li><a href="#s2">Grundaufbau der App</a></li>
<li><a href="#s3">Eintrag erfassen (Tab „Eingabe")</a></li>
<li><a href="#s4">Einträge einsehen und verwalten (Tab „Liste")</a></li>
<li><a href="#s5">Jahresstatistik (Tab „Statistik")</a></li>
<li><a href="#s6">Drucken</a></li>
<li><a href="#s7">Administration (nur Admins)</a></li>
</ol>
</nav>
<!-- ── 1. Anmelden ── -->
<section id="s1">
<h2 class="section-title"><span class="num">1</span> Anmelden</h2>
<p>Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.</p>
<ul>
<li><strong>Kürzel</strong> das persönliche BEO-Kürzel (z. B. <code>RXF</code>)</li>
<li><strong>Passwort</strong> individuell gesetztes Passwort</li>
</ul>
<div class="callout warn">
Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>.
</div>
</section>
<!-- ── 2. Grundaufbau ── -->
<section id="s2">
<h2 class="section-title"><span class="num">2</span> Grundaufbau der App</h2>
<h3>Kuppel-Auswahl</h3>
<p>Oben befinden sich vier Reiter für die vier Kuppeln:</p>
<table>
<thead><tr><th>Reiter</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>West</td><td>West-Kuppel</td></tr>
<tr><td>Ost</td><td>Ost-Kuppel</td></tr>
<tr><td>Süd</td><td>Süd-Kuppel</td></tr>
<tr><td>Pluto</td><td>Pluto-Kuppel</td></tr>
</tbody>
</table>
<p>Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.</p>
<h3>Funktions-Tabs</h3>
<p>Unterhalb der Kuppelauswahl gibt es drei Tabs:</p>
<table>
<thead><tr><th>Tab</th><th>Funktion</th></tr></thead>
<tbody>
<tr><td><strong>Eingabe</strong></td><td>Neuen Eintrag anlegen oder bestehenden bearbeiten</td></tr>
<tr><td><strong>Liste</strong></td><td>Alle Einträge monatsweise ansehen, bearbeiten oder löschen</td></tr>
<tr><td><strong>Statistik</strong></td><td>Jahresübersicht Besucher und Führungen</td></tr>
</tbody>
</table>
</section>
<!-- ── 3. Eingabe ── -->
<section id="s3">
<h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2>
<h3>Pflichtfelder</h3>
<p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p>
<table>
<thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Reguläre Führung</td><td>Reguläre öffentliche Führung</td></tr>
<tr><td>Sonderführung</td><td>Für Gruppen, Schulen etc.</td></tr>
<tr><td>Sonnenführung</td><td>Führung mit Sonnenbeobachtung</td></tr>
<tr><td>Privatführung</td><td>Private Führung</td></tr>
<tr><td>BEOS</td><td>BEO-Sitzung (keine Besucher/Objekte)</td></tr>
<tr><td>TD</td><td>Treff/Diskussion (keine Besucher/Objekte)</td></tr>
<tr><td>Beobachtung</td><td>Reine Beobachtung ohne Führung</td></tr>
<tr><td>ToT</td><td>Teleskop ohne Termin</td></tr>
<tr><td>Sonstiges</td><td>Sonstige Veranstaltung</td></tr>
</tbody>
</table>
<p><strong>Datum</strong> Datum der Veranstaltung (Standardwert: heute).</p>
<p><strong>Startzeit / Endzeit</strong> Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. <code>8</code> oder <code>23</code>) und das Feld verlassen, werden die Minuten automatisch auf <code>00</code> gesetzt.</p>
<p><strong>Besucher</strong> Anzahl der Besucher (nicht sichtbar bei BEOS und TD).</p>
<h3>Optionale Felder</h3>
<p><strong>Name / Gruppe</strong> erscheint nur bei Sonderführung; Name der Gruppe oder Person.</p>
<p><strong>BEOs</strong> beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.</p>
<p><strong>Beobachtete Objekte</strong> nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:</p>
<ul>
<li>Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.</li>
<li>Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.</li>
<li>Noch unbekannte Objekte einfach eintippen am Ende der Dropdown-Liste erscheint dann <strong>+ „[Name]" hinzufügen</strong>. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.</li>
<li>Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.</li>
</ul>
<p><strong>Bemerkungen</strong> freier Text, max. 500 Zeichen.</p>
<p><strong>Wetterdaten</strong> Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. <code>-5</code>) können direkt eingegeben werden.</p>
<h3>Eintrag speichern</h3>
<p>Schaltfläche <strong>Eintrag speichern</strong> unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.</p>
<div class="callout">
Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau.
</div>
<h3>Eintrag bearbeiten</h3>
<p>Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung <strong>Änderungen speichern</strong> klicken oder mit <strong>Abbrechen</strong> verwerfen.</p>
</section>
<!-- ── 4. Liste ── -->
<section id="s4">
<h2 class="section-title"><span class="num">4</span> Einträge einsehen und verwalten (Tab „Liste")</h2>
<h3>Werkzeugleiste</h3>
<p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p>
<ul>
<li><strong>Monatsnavigation</strong> Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; <strong>Aktueller Monat</strong> springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).</li>
<li><strong>Suchfeld</strong> Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.</li>
<li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li>
</ul>
<h3>Tabelleninhalt</h3>
<p>Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte <strong>fettgedruckt</strong> und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.</p>
<h3>Eintrag bearbeiten</h3>
<p>Stift-Symbol ✎ rechts in der Zeile.</p>
<h3>Eintrag löschen</h3>
<p>× rechts in der Zeile es erscheint ein Bestätigungsdialog.</p>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong>.</div>
<h3>Seitennavigation</h3>
<p>Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.</p>
</section>
<!-- ── 5. Statistik ── -->
<section id="s5">
<h2 class="section-title"><span class="num">5</span> Jahresstatistik (Tab „Statistik")</h2>
<p>Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.</p>
<ul>
<li><strong>Jahr</strong> oben links änderbar (Eingabefeld).</li>
<li>Darunter vier Kennzahlen-Kacheln:
<ul>
<li>Kumulierte Besucher des Jahres für die gewählte Kuppel</li>
<li>Führungstage des Jahres für die gewählte Kuppel</li>
<li>Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)</li>
<li>Führungstage für die gesamte Sternwarte</li>
</ul>
</li>
</ul>
<div class="callout">
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
</div>
</section>
<!-- ── 6. Drucken ── -->
<section id="s6">
<h2 class="section-title"><span class="num">6</span> Drucken</h2>
<p><strong>Im Tab „Liste"</strong> Schaltfläche <strong>🖨 Drucken</strong> oben rechts in der Werkzeugleiste:</p>
<ul>
<li>Es werden <strong>alle Einträge des aktuell gewählten Monats</strong> geladen (nicht nur die angezeigte Seite).</li>
<li>Die Reihenfolge ist beim Ausdruck <strong>chronologisch</strong> (ältester Eintrag zuerst).</li>
<li>Navigations- und Aktionselemente werden ausgeblendet; oben erscheint eine Kopfzeile mit Kuppelname und Druckdatum.</li>
<li>Seitenformat: A4 Hochformat, Rand 1,5 cm.</li>
</ul>
<p><strong>Im Tab „Statistik"</strong> ebenfalls eine <strong>🖨 Drucken</strong>-Schaltfläche für die Jahresstatistik.</p>
</section>
<!-- ── 7. Administration ── -->
<section id="s7">
<h2 class="section-title"><span class="num">7</span> Administration <small style="font-size:0.7em;font-weight:500;color:#6b7a9b;">(nur Admins)</small></h2>
<p>Erreichbar über die Schaltfläche <strong>Admin</strong> oben rechts (nur für Benutzer mit Admin-Rolle sichtbar). Die Admin-Seite hat zwei Tabs:</p>
<h3>Benutzerverwaltung</h3>
<p>Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.</p>
<p><strong>Passwort zurücksetzen</strong> Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort <code>welzheim</code> verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.</p>
<h3>Objektverwaltung</h3>
<p>Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.</p>
<ul>
<li><strong>Neues Objekt anlegen</strong> Feld unten ausfüllen und <strong>Hinzufügen</strong> klicken.</li>
<li><strong>Objekt umbenennen</strong> Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li>
<li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
</ul>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
</section>
</div>
</body>
</html>