Compare commits

..

29 Commits

Author SHA1 Message Date
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
36 changed files with 660 additions and 737 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
.git
.gitignore
node_modules
.next
*.md
+6 -34
View File
@@ -1,4 +1,4 @@
# Bedienungsanleitung Logbuch Sternwarte Welzheim # Bedienungsanleitung Führungsbuch Sternwarte Welzheim
## Inhaltsverzeichnis ## Inhaltsverzeichnis
@@ -19,7 +19,7 @@ Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`) - **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
- **Passwort**: individuell gesetztes Passwort - **Passwort**: individuell gesetztes Passwort
Wurde das Passwort noch nicht geändert (Anzeige „Standard"), muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`. Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
--- ---
@@ -109,7 +109,7 @@ Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Ei
Die Werkzeugleiste oben in der Liste enthält in einer Zeile: 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). - **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 Logbuchs (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. - **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). - **🖨 Drucken** siehe Abschnitt [Drucken](#6-drucken).
### Tabelleninhalt ### Tabelleninhalt
@@ -141,6 +141,8 @@ Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsse
- Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln) - Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)
- Führungstage für die gesamte Sternwarte - 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 ## 6. Drucken
@@ -174,35 +176,5 @@ Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken. - **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 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 Logbucheinträgen. - **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.
## Neue Features (Statistik Grafik Proxy)
Dieses Release ergänzt eine serverseitige Proxy-Lösung für das interne Statistik-Portal, damit geschützte Diagramme sicher eingeblendet werden können, ohne Zugangsdaten im Browser zu speichern.
- Was neu ist:
- Server-seitiger Proxy unter `/api/statistik/grafik` und Catch-all `/api/statistik/grafik/*`.
- Holt die Statistik-Seite mit Basic-Auth (serverseitig) und liefert sie an den Browser weiter.
- Schreibt die HTML-Antwort so um, dass relative Assets (CSS/JS/Images) über die Proxy-URL geladen werden (es wird ein `<base href="/api/statistik/grafik/">` eingefügt).
- Leitet auch AJAX-POSTs (z. B. `php/statistic.php`) weiter Methoden und Bodies werden beibehalten.
- Entfernt framing-blockierende Header (z. B. `X-Frame-Options`, CSP-Meta-Tags) in der proxied HTML-Antwort.
- Wichtige Environment-Variablen (nur serverseitig):
- `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 die Statistik jetzt in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, weil manche Browser (insbesondere Firefox und Safari) Probleme mit eingebetteten, geschützten Seiten machen.
- Sicherheit & Hinweise:
- Setze die `STATISTIK_...` Variablen in deiner Server-Umgebung (Docker secrets oder im Host-Umfeld). Niemals Zugangsdaten ins Repository committen.
- Die Proxy-Route ist so konfiguriert, dass Assets und AJAX-Aufrufe über den gleichen Proxy laufen, damit die Seite vollständig funktioniert.
Wenn du möchtest, pushe ich die Änderungen an `ANLEITUNG.md` in `origin/main` für dich.
Optionales manuelles Deploy (falls Docker lokal verfügbar):
```bash
./deploy.sh 1.7.8
```
+5
View File
@@ -12,7 +12,9 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG BUILD_DATE ARG BUILD_DATE
ARG NEXT_PUBLIC_FAHRKOSTEN_SATZ=15
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_PUBLIC_FAHRKOSTEN_SATZ=${NEXT_PUBLIC_FAHRKOSTEN_SATZ}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
@@ -23,11 +25,14 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache mysql-client openssh-client
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs USER nextjs
+36 -11
View File
@@ -21,6 +21,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe'); const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null); const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
const grafikSrc = '/api/statistik/grafik'; const grafikSrc = '/api/statistik/grafik';
@@ -47,24 +48,48 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
window.location.href = '/login'; 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 ( return (
<div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0"> <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"> <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 */} {/* Header */}
<div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden"> <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> <span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Logbuch für {activeKuppel}-Kuppel Führungsbuch für {activeKuppel}-Kuppel
</h1> </h1>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{role?.includes('admin') && ( {role?.includes('admin') && (
<a <>
href="/admin" <button
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" onClick={handleBackup}
> disabled={backupState === 'running'}
Admin className={`text-xs sm:text-sm px-2 sm:px-3 py-1.5 rounded-lg text-gray-900 disabled:opacity-50 ${
</a> 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 <button
onClick={handleLogout} onClick={handleLogout}
@@ -161,7 +186,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{activeTab === '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="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="hidden print:block mb-4"> <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 className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div> </div>
<LogbuchList <LogbuchList
@@ -179,7 +204,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{activeTab === 'statistik' && ( {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="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"> <div className="flex justify-between items-center mb-2 print:hidden gap-2">
<span className="text-sm font-semibold text-gray-600">Statistik (alle Kuppeln)</span> <span className="text-sm font-semibold text-gray-900">Statistik (alle Kuppeln)</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => window.print()} onClick={() => window.print()}
@@ -203,7 +228,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && ( {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="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"> <div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Fahrkostenabrechnung</span> <span className="text-sm font-semibold text-gray-900">Fahrkostenabrechnung</span>
<button <button
onClick={() => window.print()} onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg" className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
+37 -4
View File
@@ -12,7 +12,9 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const router = useRouter(); const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editKategorie, setEditKategorie] = useState<string>('stern');
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newKategorie, setNewKategorie] = useState<string>('stern');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -24,7 +26,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const res = await fetch('/api/objekte/' + id, { const res = await fetch('/api/objekte/' + id, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }), body: JSON.stringify({ name: trimmed, kategorie: editKategorie }),
}); });
setBusy(false); setBusy(false);
if (!res.ok) { if (!res.ok) {
@@ -59,7 +61,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
const res = await fetch('/api/objekte', { const res = await fetch('/api/objekte', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }), body: JSON.stringify({ name: trimmed, kategorie: newKategorie }),
}); });
setBusy(false); setBusy(false);
if (!res.ok) { if (!res.ok) {
@@ -85,6 +87,15 @@ export default function ObjekteManager({ initialObjekte }: Props) {
placeholder="Neues Objekt…" 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" 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 <button
type="submit" type="submit"
disabled={busy} disabled={busy}
@@ -100,6 +111,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
<tr> <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 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">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="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th>
<th className="px-4 py-3 w-36"></th> <th className="px-4 py-3 w-36"></th>
</tr> </tr>
@@ -125,6 +137,27 @@ export default function ObjekteManager({ initialObjekte }: Props) {
obj.Name obj.Name
)} )}
</td> </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"> <td className="px-4 py-2 text-gray-500 hidden sm:table-cell">
{obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'} {obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'}
</td> </td>
@@ -151,7 +184,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
<span className="flex justify-end gap-2"> <span className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setError(''); }} onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setEditKategorie(obj.Kategorie); setError(''); }}
disabled={busy} 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" 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"
> >
@@ -172,7 +205,7 @@ export default function ObjekteManager({ initialObjekte }: Props) {
))} ))}
{initialObjekte.length === 0 && ( {initialObjekte.length === 0 && (
<tr> <tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td> <td colSpan={5} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td>
</tr> </tr>
)} )}
</tbody> </tbody>
+9 -26
View File
@@ -2,39 +2,27 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import { query } from '@/lib/db'; import * as phpdb from '@/lib/phpdb';
export type { BeoUser } from '@/lib/phpdb';
export interface ObjektRow { export interface ObjektRow {
ID: number; ID: number;
Name: string; Name: string;
LastUsed: string | null; LastUsed: string | null;
Kategorie: string;
} }
export async function listObjekte(): Promise<ObjektRow[]> { export async function listObjekte(): Promise<ObjektRow[]> {
const session = await getSession(); const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/'); if (!session || !session.role?.includes('admin')) redirect('/');
const rows = await query('SELECT ID, Name, LastUsed FROM objekte ORDER BY Name ASC', []); return phpdb.listObjekteAdmin();
return rows as ObjektRow[];
} }
export interface BeoUser { export async function listUsers(): Promise<phpdb.BeoUser[]> {
id: number;
kürzel: string | null;
name: string;
vorname: string | null;
role: string | null;
hasPw: boolean;
}
export async function listUsers(): Promise<BeoUser[]> {
const session = await getSession(); const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/'); if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listUsers();
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 resetPassword( export async function resetPassword(
@@ -46,16 +34,11 @@ export async function resetPassword(
return { error: 'Keine Berechtigung.' }; return { error: 'Keine Berechtigung.' };
} }
const idRaw = formData.get('id'); const id = Number(formData.get('id'));
const id = Number(idRaw);
if (!id || isNaN(id)) { if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' }; return { error: 'Ungültige Benutzer-ID.' };
} }
await query( await phpdb.resetBeoPassword(id);
'UPDATE beos SET pw = NULL, MustChangePassword = 1 WHERE id = ?',
[id]
);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' }; return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
} }
+1 -1
View File
@@ -33,7 +33,7 @@ export default async function AdminPage({
<div className="min-h-screen bg-white py-4 px-4"> <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]"> <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-4"> <div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link> <Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div> </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 { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET() { export async function GET() {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { try {
const rows = await query( const rows = await phpdb.getBeos();
'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 }[];
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/beos:', error); console.error('GET /api/beos:', error);
+3 -21
View File
@@ -1,15 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export interface FahrkostenRow { export type { FahrkostenRow } from '@/lib/phpdb';
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
const EXCLUDED = "'PrF','Beob','BEOS','TD','ToT'";
export async function GET(req: Request) { export async function GET(req: Request) {
const session = await getSession(); const session = await getSession();
@@ -22,18 +15,7 @@ export async function GET(req: Request) {
} }
try { try {
const rows = await query( const rows = await phpdb.getFahrkosten(ab);
'SELECT b.id AS ID, b.`kürzel` AS Kuerzel,' +
' CONCAT(IFNULL(b.vorname, \'\'), IF(b.vorname IS NOT NULL, \' \', \'\'), b.name) AS Name,' +
' COUNT(DISTINCT l.ID) AS Anzahl' +
' FROM beos b' +
' JOIN logbuch_beos lb ON lb.BeoID = b.id' +
' JOIN logbuch l ON l.ID = lb.LogbuchID' +
' WHERE l.Beginn >= ? AND l.ArtFuehrung NOT IN (' + EXCLUDED + ')' +
' GROUP BY b.id, b.`kürzel`, b.name, b.vorname' +
' ORDER BY b.name ASC',
[ab + ' 00:00:00']
) as FahrkostenRow[];
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/fahrkosten:', error); console.error('GET /api/fahrkosten:', error);
+22 -71
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; 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 }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession(); const session = await getSession();
@@ -11,65 +11,25 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[];
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Ändern dieses Eintrags' }, { status: 403 });
}
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await getPool().execute( await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
'UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, SonderName=?, Beginn=?, Ende=?, Besucher=?,' + Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
' Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? WHERE ID=?', Besucher: Besucher ?? 0,
[ beoIds: beoIds ?? [],
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende, objekte: objekte ?? [],
Besucher ?? 0, Bemerkungen: Bemerkungen ?? null,
Bemerkungen?.slice(0, 500) || null, Wetter: Wetter ?? 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]
);
}
triggerBackup();
return NextResponse.json({ ok: true }); 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); console.error('PUT /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
} }
@@ -83,22 +43,13 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const logbuchId = parseInt(id); const logbuchId = parseInt(id);
try { try {
const existingRows = await query('SELECT ID FROM logbuch WHERE ID = ?', [logbuchId]) as { ID: number }[]; await phpdb.deleteLogbuch(logbuchId, session.beoId, session.role ?? '');
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
}
const isAdmin = session.role?.includes('admin');
const beoRows = await query('SELECT COUNT(*) AS cnt FROM logbuch_beos WHERE LogbuchID = ? AND BeoID = ?', [logbuchId, session.beoId]) as { cnt: number }[];
const isBeo = (beoRows[0]?.cnt ?? 0) > 0;
if (!isAdmin && !isBeo) {
return NextResponse.json({ error: 'Keine Berechtigung zum Löschen dieses Eintrags' }, { status: 403 });
}
await query('DELETE FROM logbuch WHERE ID = ?', [logbuchId]);
return NextResponse.json({ ok: true }); 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); console.error('DELETE /api/logbuch/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
} }
+20 -114
View File
@@ -1,89 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import type { SelectedObjekt } from '@/types/logbuch'; import { triggerBackup } from '@/lib/backup';
import * as phpdb from '@/lib/phpdb';
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';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession(); 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 { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West'; const kuppel = searchParams.get('kuppel') || 'West';
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500); const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500);
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0); const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
const month = searchParams.get('month') || ''; const month = searchParams.get('month') || '';
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC'; const order = searchParams.get('order') === 'asc' ? 'asc' : 'desc';
const search = (searchParams.get('search') || '').trim(); const search = (searchParams.get('search') || '').trim();
if (search) {
const pattern = '%' + search + '%';
const searchParams2 = [kuppel, pattern, pattern, pattern];
const countSQL =
'SELECT COUNT(*) AS total FROM (' +
'SELECT l.ID FROM logbuch l' +
' 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' +
" HAVING (MAX(l.Bemerkungen) LIKE ? OR GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') LIKE ? OR GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') LIKE ?)" +
') AS sub';
const listSQL = LIST_SQL +
' HAVING (MAX(l.Bemerkungen) LIKE ? OR BEOs LIKE ? OR Objekte LIKE ?)' +
` ORDER BY l.Beginn DESC LIMIT ${limit} OFFSET ${offset}`;
try {
const [countRows, entries] = await Promise.all([
query(countSQL, searchParams2) as Promise<{ total: number }[]>,
query(listSQL, searchParams2),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) {
console.error('GET /api/logbuch (search):', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
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];
}
try { try {
const [countRows, entries] = await Promise.all([ const result = await phpdb.listLogbuch({ kuppel, limit, offset, month, search, order });
query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>, return NextResponse.json(result);
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` ORDER BY l.Beginn ${order} LIMIT ${limit} OFFSET ${offset}`, params),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) { } catch (error) {
console.error('GET /api/logbuch:', error); console.error('GET /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -98,46 +32,18 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const pool = getPool(); const result = await phpdb.createLogbuch({
const [result] = await pool.execute( Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
'INSERT INTO logbuch (Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' + Besucher: Besucher ?? 0,
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', beoIds: beoIds ?? [],
[ objekte: objekte ?? [],
Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende, Bemerkungen: Bemerkungen ?? null,
Besucher ?? 0, Wetter: Wetter ?? null,
Bemerkungen?.slice(0, 500) || null, created_by: session.beoId,
Wetter?.temp ?? null, });
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
session.beoId,
]
) as [{ insertId: number }, unknown];
const logbuchId = result.insertId; triggerBackup();
return NextResponse.json(result, { status: 201 });
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 });
} catch (error) { } catch (error) {
console.error('POST /api/logbuch:', error); console.error('POST /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+7 -5
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession(); const session = await getSession();
@@ -10,11 +10,13 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
const { id } = await params; const { id } = await params;
const numId = Number(id); const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
const { name } = await req.json(); const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim(); const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 }); if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
await query('UPDATE objekte SET Name = ? WHERE ID = ?', [trimmed, numId]); const VALID = ['stern', 'sonne', 'stern,sonne'];
return NextResponse.json({ ID: numId, Name: trimmed }); const kat: string | undefined = VALID.includes(kategorie) ? kategorie : undefined;
const result = await phpdb.updateObjekt(numId, trimmed, kat);
return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error('PUT /api/objekte/[id]:', error); console.error('PUT /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -29,7 +31,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
const { id } = await params; const { id } = await params;
const numId = Number(id); const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
await query('DELETE FROM objekte WHERE ID = ?', [numId]); await phpdb.deleteObjekt(numId);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('DELETE /api/objekte/[id]:', error); console.error('DELETE /api/objekte/[id]:', error);
+10 -6
View File
@@ -1,12 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; 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(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { 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); return NextResponse.json(rows);
} catch (error) { } catch (error) {
console.error('GET /api/objekte:', error); console.error('GET /api/objekte:', error);
@@ -19,11 +21,13 @@ export async function POST(req: NextRequest) {
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }); if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try { try {
const { name } = await req.json(); const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim(); const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 }); if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const result = await query('INSERT INTO objekte (Name) VALUES (?)', [trimmed]) as { insertId: number }; const VALID = ['stern', 'sonne', 'stern,sonne'];
return NextResponse.json({ ID: result.insertId, Name: trimmed }, { status: 201 }); const kat: string = VALID.includes(kategorie) ? kategorie : 'stern';
const result = await phpdb.createObjekt(trimmed, kat);
return NextResponse.json(result, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/objekte:', error); console.error('POST /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+11 -1
View File
@@ -93,7 +93,17 @@ export async function grafikProxy(req: Request, slug?: string[]) {
}); });
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || ''; const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
if (contentType.toLowerCase().includes('text/html')) { 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 text = Buffer.from(bodyBuf).toString('utf8');
const cleaned = rewriteHtml(text); const cleaned = rewriteHtml(text);
outHeaders['content-type'] = 'text/html; charset=utf-8'; outHeaders['content-type'] = 'text/html; charset=utf-8';
+3 -61
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession(); const session = await getSession();
@@ -10,66 +10,8 @@ export async function GET(request: NextRequest) {
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10); const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10);
try { try {
const monthlyRows = await query( const result = await phpdb.getStatistik(year);
'SELECT' + return NextResponse.json(result);
' MONTH(Beginn) AS monat,' +
" COUNT(CASE WHEN ArtFuehrung IN ('RF','SF','SonF','PrF') THEN 1 END) AS tageFuehrungen," +
" COUNT(CASE WHEN ArtFuehrung = 'Beob' THEN 1 END) AS tageBeob," +
" COUNT(CASE WHEN ArtFuehrung = 'TD' THEN 1 END) AS tageTD," +
" COUNT(CASE WHEN ArtFuehrung = 'Sonst' THEN 1 END) AS tageSonst," +
" COUNT(CASE WHEN ArtFuehrung = 'BEOS' THEN 1 END) AS tageBEOS," +
" COUNT(CASE WHEN ArtFuehrung = 'ToT' THEN 1 END) AS tagesToT," +
" COUNT(CASE WHEN ArtFuehrung IN ('RF','SF','SonF','PrF','Beob','TD','Sonst','BEOS','ToT') THEN 1 END) AS tageGesamt," +
" SUM(CASE WHEN ArtFuehrung = 'RF' THEN Besucher ELSE 0 END) AS besucherRF," +
" SUM(CASE WHEN ArtFuehrung = 'SF' THEN Besucher ELSE 0 END) AS besucherSF," +
" SUM(CASE WHEN ArtFuehrung = 'SonF' THEN Besucher ELSE 0 END) AS besucherSonF," +
" SUM(CASE WHEN ArtFuehrung = 'PrF' THEN Besucher ELSE 0 END) AS besucherPrF," +
" SUM(CASE WHEN ArtFuehrung = 'ToT' THEN Besucher ELSE 0 END) AS besucherToT," +
" SUM(CASE WHEN ArtFuehrung IN ('RF','SF','SonF','PrF','ToT') THEN Besucher ELSE 0 END) AS besucherGesamt" +
' FROM logbuch' +
' WHERE YEAR(Beginn) = ?' +
' GROUP BY MONTH(Beginn)' +
' ORDER BY monat',
[year]
) as {
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;
}[];
const cumulativeRows = await query(
"SELECT SUM(CASE WHEN ArtFuehrung IN ('RF','SF','SonF','PrF','ToT') THEN Besucher ELSE 0 END) AS total" +
' FROM logbuch WHERE YEAR(Beginn) = ?',
[year]
) as { total: number | null }[];
const tageRows = await query(
"SELECT COUNT(*) AS tage FROM logbuch WHERE YEAR(Beginn) = ? AND ArtFuehrung IN ('RF','SF','SonF','PrF','Beob','TD','Sonst','BEOS','ToT')",
[year]
) as { tage: number }[];
return NextResponse.json({
monthly: monthlyRows.map((r) => ({
monat: Number(r.monat),
tageFuehrungen: Number(r.tageFuehrungen),
tageBeob: Number(r.tageBeob),
tageTD: Number(r.tageTD),
tageSonst: Number(r.tageSonst),
tageBEOS: Number(r.tageBEOS),
tagesToT: Number(r.tagesToT),
tageGesamt: Number(r.tageGesamt),
besucherRF: Number(r.besucherRF),
besucherSF: Number(r.besucherSF),
besucherSonF: Number(r.besucherSonF),
besucherPrF: Number(r.besucherPrF),
besucherToT: Number(r.besucherToT),
besucherGesamt: Number(r.besucherGesamt),
})),
cumulative: Number(cumulativeRows[0]?.total ?? 0),
tage: Number(tageRows[0]?.tage ?? 0),
year,
});
} catch (error) { } catch (error) {
console.error('GET /api/statistik:', error); console.error('GET /api/statistik:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
+2 -5
View File
@@ -3,7 +3,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getSession, createSession } from '@/lib/session'; import { getSession, createSession } from '@/lib/session';
import { hashPassword } from '@/lib/auth'; import { hashPassword } from '@/lib/auth';
import { query } from '@/lib/db'; import { updateBeoPassword } from '@/lib/phpdb';
export async function changePassword( export async function changePassword(
_prevState: { error: string } | undefined, _prevState: { error: string } | undefined,
@@ -28,10 +28,7 @@ export async function changePassword(
} }
const hashed = await hashPassword(newPassword); const hashed = await hashPassword(newPassword);
await query( await updateBeoPassword(session.beoId, hashed);
'UPDATE beos SET pw = ?, MustChangePassword = 0 WHERE id = ?',
[hashed, session.beoId]
);
await createSession({ await createSession({
kuerzel: session.kuerzel, kuerzel: session.kuerzel,
+1 -1
View File
@@ -11,7 +11,7 @@ export default function ChangePasswordPage() {
return ( return (
<div className="min-h-screen bg-white py-4 px-4"> <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]"> <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="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8"> <div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Logbuch — Sternwarte Welzheim', title: 'Führungsbuch — Sternwarte Welzheim',
description: 'Logbuch für die Sternwarte Welzheim', description: 'Führungsbuch für die Sternwarte Welzheim',
}; };
export const viewport: Viewport = { 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"> <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]"> <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"> <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>
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
+2 -2
View File
@@ -1,5 +1,5 @@
Erstelle ein Logbuch für die Sternwarte in Welzheim. 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 Logbuch. Alle 4 Logbücher sind identisch gebaut. + 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 Datenbank soll eine MYSQL verwendet werden
+ Als Sprache soll nextjs zum Einsatz kommen + Als Sprache soll nextjs zum Einsatz kommen
+ Der Zugang zu der Webseite soll per User/Passwort gesichert werden + Der Zugang zu der Webseite soll per User/Passwort gesichert werden
+4 -4
View File
@@ -35,11 +35,11 @@ export default function Fahrkosten() {
const gesamt = rows ? rows.reduce((s, r) => s + r.Anzahl * SATZ, 0) : 0; 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'; 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 thNarrowCls = `${thCls} w-16`;
const tdCls = 'px-3 py-2 border border-gray-200 text-sm'; const tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const tdNarrowCls = `${tdCls} w-16`; const tdNarrowCls = `${tdCls} w-16`;
const tdNumCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums w-20'; 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', { const abFormatted = new Date(ab + 'T00:00:00').toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
@@ -53,7 +53,7 @@ export default function Fahrkosten() {
type="date" type="date"
value={ab} value={ab}
onChange={(e) => setAb(e.target.value)} onChange={(e) => setAb(e.target.value)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" 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> </div>
+18 -21
View File
@@ -120,8 +120,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {}); .catch(() => {});
} }
if (editEntry && editEntry.Objekte) { if (editEntry && editEntry.Objekte) {
const names = editEntry.Objekte.split(', ').map((n) => n.trim()); const allNames = editEntry.Objekte.split(', ').map((n) => n.trim());
fetch('/api/objekte') 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((r) => r.json())
.then((all: { ID: number; Name: string }[]) => { .then((all: { ID: number; Name: string }[]) => {
const result: SelectedObjekt[] = names.map((name) => { const result: SelectedObjekt[] = names.map((name) => {
@@ -134,12 +137,10 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
} }
}, [editEntry]); }, [editEntry]);
// Objekte-Vorauswahl je nach Art der Führung; Besucher zurücksetzen wenn nicht relevant // Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung
useEffect(() => { useEffect(() => {
if (artFuehrung === SONNE_ART) { setObjekte([]);
setObjekte([{ ID: null, Name: 'Sonne' }]); if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
} else if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setObjekte([]);
setBesucher(''); setBesucher('');
} }
}, [artFuehrung]); }, [artFuehrung]);
@@ -172,7 +173,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
Ende: ende, Ende: ende,
Besucher: besucher === '' ? 0 : besucher, Besucher: besucher === '' ? 0 : besucher,
beoIds: beos.map((b) => b.ID), beoIds: beos.map((b) => b.ID),
objekte: showObjekte ? objekte : [], objekte: showObjekte ? (isSonne ? [{ ID: null, Name: 'Sonne' }, ...objekte] : objekte) : [],
Bemerkungen: bemerkungen, Bemerkungen: bemerkungen,
Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 }, Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 },
}; };
@@ -205,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'; const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5';
return ( return (
@@ -273,7 +274,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)} onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)}
min={0} min={0}
max={9999} 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> </div>
)} )}
@@ -304,16 +305,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{showObjekte && ( {showObjekte && (
<div> <div>
<label className={labelCls}>Beobachtete Objekte</label> <label className={labelCls}>Beobachtete Objekte</label>
{isSonne ? ( <ObjektSelector
<div className="flex items-center gap-2"> selected={objekte}
<span className="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1.5 rounded-full"> onChange={setObjekte}
Sonne kategorie={isSonne ? 'sonne' : 'stern'}
</span> fixedItems={isSonne ? [{ ID: null, Name: 'Sonne' }] : []}
<span className="text-xs text-gray-500">(bei Sonnenführung fest vorgegeben)</span> />
</div>
) : (
<ObjektSelector selected={objekte} onChange={setObjekte} />
)}
</div> </div>
)} )}
@@ -327,7 +324,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
value={bemerkungen} value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))} onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={2} 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)" placeholder="Freier Text (max. 500 Zeichen)"
/> />
</div> </div>
+8 -8
View File
@@ -148,7 +148,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
value={month} value={month}
max={currentMonth()} max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)} onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]" 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 <button
onClick={() => setMonth((m) => nextMonth(m))} onClick={() => setMonth((m) => nextMonth(m))}
@@ -168,7 +168,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Suche in Bemerkungen, Objekte, BEOs…" placeholder="Suche in Bemerkungen, Objekte, BEOs…"
className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]" 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 ? ( {search ? (
<button <button
@@ -194,11 +194,11 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
); );
const cell = compact const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs' ? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
: 'px-3 py-2 border border-gray-200'; : 'px-3 py-2 border border-gray-200 text-gray-900';
const head = compact const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold' ? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
: 'px-3 py-2 border border-gray-300'; : 'px-3 py-2 border border-gray-300 text-gray-900';
const displayEntries = printEntries ?? entries; const displayEntries = printEntries ?? entries;
@@ -306,13 +306,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
<button <button
onClick={() => setPageState({ page: page - 1, key: filterKey })} onClick={() => setPageState({ page: page - 1, key: filterKey })}
disabled={page === 0} 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> > Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span> <span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button <button
onClick={() => setPageState({ page: page + 1, key: filterKey })} onClick={() => setPageState({ page: page + 1, key: filterKey })}
disabled={(page + 1) * limit >= total} 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> >Weiter </button>
</div> </div>
)} )}
+19 -6
View File
@@ -6,9 +6,11 @@ import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
interface Props { interface Props {
selected: SelectedObjekt[]; selected: SelectedObjekt[];
onChange: (objekte: SelectedObjekt[]) => void; 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 [all, setAll] = useState<ObjektOption[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -16,11 +18,11 @@ export default function ObjektSelector({ selected, onChange }: Props) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
fetch('/api/objekte') fetch('/api/objekte?kategorie=' + kategorie)
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); }) .then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
.then(setAll) .then(setAll)
.catch(() => {}); .catch(() => {});
}, []); }, [kategorie]);
useEffect(() => { useEffect(() => {
function handleOutside(e: MouseEvent) { function handleOutside(e: MouseEvent) {
@@ -32,14 +34,17 @@ export default function ObjektSelector({ selected, onChange }: Props) {
return () => document.removeEventListener('mousedown', handleOutside); return () => document.removeEventListener('mousedown', handleOutside);
}, [dropdownOpen]); }, [dropdownOpen]);
const fixedNamesLower = new Set(fixedItems.map((o) => o.Name.toLowerCase()));
const selectedNames = new Set(selected.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 const filtered = search
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase())) ? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
: available; : available;
const searchTrimmed = search.trim(); const searchTrimmed = search.trim();
const alreadySelected = searchTrimmed !== '' && selectedNames.has(searchTrimmed.toLowerCase()); const alreadySelected = searchTrimmed !== '' && (selectedNames.has(searchTrimmed.toLowerCase()) || fixedNamesLower.has(searchTrimmed.toLowerCase()));
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase()); const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase());
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch; const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
@@ -51,7 +56,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
function addNew(name: string) { function addNew(name: string) {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed || selectedNames.has(trimmed.toLowerCase())) return; if (!trimmed || selectedNames.has(trimmed.toLowerCase()) || fixedNamesLower.has(trimmed.toLowerCase())) return;
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase()); const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase());
if (existing) { if (existing) {
onChange([...selected, { ID: existing.ID, Name: existing.Name }]); onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
@@ -79,6 +84,14 @@ export default function ObjektSelector({ selected, onChange }: Props) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-2"> <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) => ( {selected.map((o) => (
<span <span
key={o.Name} key={o.Name}
+11 -11
View File
@@ -61,14 +61,14 @@ export default function Statistik() {
return rows.reduce((s, r) => s + (r[key] as number), 0); 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'; 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'; 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'; 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'; 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'; 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'; 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'; 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'; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -80,7 +80,7 @@ export default function Statistik() {
onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())} onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
min={2000} min={2000}
max={2100} 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> </div>
@@ -156,11 +156,11 @@ export default function Statistik() {
<div className="grid grid-cols-2 gap-4 w-full max-w-sm"> <div className="grid grid-cols-2 gap-4 w-full max-w-sm">
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white"> <div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Besucher {year}</div> <div className="text-xs text-gray-500 mb-1">Besucher {year}</div>
<div className="text-2xl font-bold">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div> <div className="text-2xl font-bold text-gray-900">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
</div> </div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white"> <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-xs text-gray-500 mb-1">Führungen {year}</div>
<div className="text-2xl font-bold">{data?.tage ?? 0}</div> <div className="text-2xl font-bold text-gray-900">{data?.tage ?? 0}</div>
</div> </div>
</div> </div>
</div> </div>
+2 -68
View File
@@ -1,79 +1,14 @@
services: 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: logbuch_app:
image: docker.citysensor.de/logbuch:latest image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app container_name: logbuch_app
restart: unless-stopped restart: unless-stopped
environment: environment:
DB_HOST: logbuch_mysql PHP_DB_URL: ${PHP_DB_URL}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
DB_PORT: 3306
AUTH_SECRET: ${AUTH_SECRET} AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production NODE_ENV: production
ports: ports:
- 127.0.0.1:${APP_PORT:-3000}:3000 - 127.0.0.1:${APP_PORT:-3000}:3000
depends_on:
logbuch_mysql:
condition: service_healthy
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.logbuch.entrypoints=http - traefik.http.routers.logbuch.entrypoints=http
@@ -88,6 +23,7 @@ services:
networks: networks:
- proxy - proxy
- gitea-internal - gitea-internal
networks: networks:
proxy: proxy:
name: dockge_default name: dockge_default
@@ -95,5 +31,3 @@ networks:
gitea-internal: gitea-internal:
name: gitea_gitea-internal name: gitea_gitea-internal
external: true external: true
volumes:
db_data: null
+6 -49
View File
@@ -1,64 +1,21 @@
services: 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: logbuch_app:
image: docker.citysensor.de/logbuch:latest image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app container_name: logbuch_app
restart: unless-stopped restart: unless-stopped
env_file: .env
environment: 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 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: ports:
- "127.0.0.1:${APP_PORT:-3000}:3000" - "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on: extra_hosts:
logbuch_mysql: - "host.docker.internal:host-gateway"
condition: service_healthy
networks: networks:
- logbuch_net - logbuch_net
networks: networks:
logbuch_net: logbuch_net:
driver: bridge driver: bridge
volumes:
db_data:
+5 -27
View File
@@ -1,34 +1,13 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { query } from './db'; import { getBeoByKuerzel, getBeoByName } from './phpdb';
export interface Beo { export type { Beo } from './phpdb';
id: number; import type { Beo } from './phpdb';
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 async function getBeoByLogin(login: string): Promise<Beo | null> { 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); const byKuerzel = await getBeoByKuerzel(login);
if (byKuerzel) return byKuerzel; if (byKuerzel) return byKuerzel;
return getBeoByName(login);
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;
} }
export async function verifyCredentials( export async function verifyCredentials(
@@ -41,8 +20,7 @@ export async function verifyCredentials(
if (!beo.pw) { if (!beo.pw) {
const defaultPw = process.env.DEFAULT_PASSWORD; const defaultPw = process.env.DEFAULT_PASSWORD;
if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!'); if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!');
const valid = password === defaultPw; return { beo, valid: password === defaultPw };
return { beo, valid };
} }
const valid = await bcrypt.compare(password, beo.pw); 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_COOKIE_NAME = 'logbuch_session';
const SESSION_DURATION = 60 * 60 * 1000; const SESSION_DURATION = 60 * 60 * 1000;
const secretKey = process.env.AUTH_SECRET; function getKey(): Uint8Array {
if (!secretKey) { const secretKey = process.env.AUTH_SECRET;
throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!'); 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 { export interface SessionData {
kuerzel: string; kuerzel: string;
@@ -25,12 +25,12 @@ async function encrypt(payload: SessionData): Promise<string> {
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt)) .setExpirationTime(new Date(payload.expiresAt))
.sign(key); .sign(getKey());
} }
async function decrypt(token: string): Promise<SessionData | null> { async function decrypt(token: string): Promise<SessionData | null> {
try { try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] }); const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData; return payload as unknown as SessionData;
} catch { } catch {
return null; return null;
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.9", "version": "1.10.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,8 +11,7 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
"mysql2": "^3.22.3", "next": "16.1.6",
"next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
+48 -135
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Logbuch Sternwarte Welzheim</title> <title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -15,14 +15,12 @@
background: #f4f6fb; background: #f4f6fb;
} }
/* ── Layout ── */
.page { .page {
max-width: 860px; max-width: 860px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem 4rem; padding: 2rem 1.5rem 4rem;
} }
/* ── Header ── */
header { header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%); background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff; color: #fff;
@@ -33,24 +31,10 @@
align-items: center; align-items: center;
gap: 1.2rem; gap: 1.2rem;
} }
header .star { header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
font-size: 2.8rem; header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
flex-shrink: 0; header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
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;
}
/* ── TOC ── */
nav.toc { nav.toc {
background: #fff; background: #fff;
border: 1px solid #d8e0f0; border: 1px solid #d8e0f0;
@@ -59,36 +43,21 @@
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
nav.toc h2 { nav.toc h2 {
font-size: 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
font-weight: 700; letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7a9b;
margin-bottom: 0.75rem;
} }
nav.toc ol { nav.toc ol {
list-style: none; list-style: none; counter-reset: toc;
counter-reset: toc; display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
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 { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before { nav.toc li::before {
content: counter(toc) "."; content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-size: 0.8rem; font-weight: 700; min-width: 1.4rem;
color: #85b7d7;
font-weight: 700;
min-width: 1.4rem;
}
nav.toc a {
color: #2e4e8a;
text-decoration: none;
font-size: 0.92rem;
} }
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; } nav.toc a:hover { text-decoration: underline; }
/* ── Sections ── */
section { section {
background: #fff; background: #fff;
border: 1px solid #d8e0f0; border: 1px solid #d8e0f0;
@@ -98,127 +67,69 @@
} }
h2.section-title { h2.section-title {
font-size: 1.15rem; font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
font-weight: 700; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
color: #1a2d5a;
margin-bottom: 1.2rem;
padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7; border-bottom: 2px solid #85b7d7;
display: flex; display: flex; align-items: center; gap: 0.6rem;
align-items: center;
gap: 0.6rem;
} }
h2.section-title .num { h2.section-title .num {
background: #85b7d7; background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
color: #1a2d5a; width: 1.6rem; height: 1.6rem; border-radius: 50%;
font-size: 0.78rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
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 { h3 {
font-size: 0.95rem; font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
font-weight: 700; margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
color: #2e4e8a;
margin: 1.3rem 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
h3:first-child { margin-top: 0; } h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; } p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; } p:last-child { margin-bottom: 0; }
/* ── Lists ── */ ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
ul, ol {
padding-left: 1.4rem;
margin-bottom: 0.7rem;
}
li { margin-bottom: 0.35rem; } li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; } li:last-child { margin-bottom: 0; }
/* ── Tables ── */ table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin: 0.8rem 0;
}
th { th {
background: #eef2fa; background: #eef2fa; color: #2e4e8a; font-weight: 700;
color: #2e4e8a; text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
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;
} }
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; } tr:nth-child(even) td { background: #f7f9fd; }
/* ── Code / kbd ── */
code { code {
background: #eef2fa; background: #eef2fa; color: #1a2d5a;
color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace; font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
padding: 0.1em 0.35em;
border-radius: 4px;
} }
/* ── Callout boxes ── */
.callout { .callout {
background: #eef6fb; background: #eef6fb; border-left: 4px solid #85b7d7;
border-left: 4px solid #85b7d7; border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
border-radius: 0 8px 8px 0; margin: 0.9rem 0; font-size: 0.9rem;
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;
} }
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; } strong { font-weight: 700; }
/* ── Responsive ── */ .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) { @media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; } nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; } header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; } section { padding: 1.2rem 1rem; }
} }
.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 print { @media print {
body { background: #fff; font-size: 12pt; } body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; } .page { max-width: none; padding: 0; }
@@ -233,13 +144,13 @@
<body> <body>
<div class="page"> <div class="page">
<a href="/" class="back-btn">← Zurück zum Logbuch</a> <a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header> <header>
<div class="star"></div> <div class="star"></div>
<div> <div>
<h1>Bedienungsanleitung</h1> <h1>Bedienungsanleitung</h1>
<p>Logbuch Sternwarte Welzheim</p> <p>Führungsbuch Sternwarte Welzheim</p>
</div> </div>
</header> </header>
@@ -265,7 +176,7 @@
<li><strong>Passwort</strong> individuell gesetztes Passwort</li> <li><strong>Passwort</strong> individuell gesetztes Passwort</li>
</ul> </ul>
<div class="callout warn"> <div class="callout warn">
Wurde das Passwort noch nicht geändert (Anzeige „Standard"), muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>. 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> </div>
</section> </section>
@@ -303,7 +214,6 @@
<h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2> <h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2>
<h3>Pflichtfelder</h3> <h3>Pflichtfelder</h3>
<p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p> <p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p>
<table> <table>
<thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead> <thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead>
@@ -362,7 +272,7 @@
<p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p> <p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p>
<ul> <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>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 Logbuchs (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>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> <li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li>
</ul> </ul>
@@ -395,6 +305,9 @@
</ul> </ul>
</li> </li>
</ul> </ul>
<div class="callout">
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
</div>
</section> </section>
<!-- ── 6. Drucken ── --> <!-- ── 6. Drucken ── -->
@@ -428,7 +341,7 @@
<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 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> <li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
</ul> </ul>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Logbucheinträgen.</div> <div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
</section> </section>
</div> </div>