Compare commits

..

1 Commits

Author SHA1 Message Date
admin 64acfdda6f Fix iOS text color, viewport meta tag, and security improvements
- Add viewport meta tag to prevent iOS zoom/scaling issues
- Fix text color on iOS Safari (explicit text-gray-900 on buttons, inputs, TimePicker5)
- Add session checks to /api/beos, /api/objekte, /api/wetter
- Revert iframe embedding (X-Frame-Options: DENY, SameSite: lax)
- docker-compose.prod.yml: fix DB_PORT=3306 for production
- Add docker-compose.prod.yml, .env.prod.example, dump/import scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 17:38:08 +02:00
63 changed files with 686 additions and 3502 deletions
-27
View File
@@ -1,27 +0,0 @@
{
"name": "Logbuch Dev",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 3306],
"portsAttributes": {
"3000": {
"label": "Next.js Dev Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "silent"
}
},
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next"
]
}
}
}
-17
View File
@@ -1,17 +0,0 @@
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:20
volumes:
- ..:/workspace:cached
environment:
DB_HOST: host.docker.internal
DB_USER: logbuch
DB_PASS: Ds!?f?f6X5B2
DB_NAME: sternwarte
DB_PORT: 3336
AUTH_SECRET: 75c3075e200d50f2273c60edcea5aca58796831e2c99ce2a69fca0005d5920cd
DEFAULT_PASSWORD: welzheim
NODE_ENV: development
extra_hosts:
- "host.docker.internal:host-gateway"
command: sleep infinity
-7
View File
@@ -1,7 +0,0 @@
.env
.env.*
.git
.gitignore
node_modules
.next
*.md
-2
View File
@@ -39,5 +39,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
backup.git/
DB_BACKUP/
-10
View File
@@ -1,10 +0,0 @@
### Zugriff zur Datenbank:
Die Remote-Datenbank auf logbuch.fuerst-.stuttgart.de wird zum lokalen entwickeln über einen SSH-Tunner erreicht:
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
Dieser ist vor dem Starten des Programme einmal einzurichten!!
-180
View File
@@ -1,180 +0,0 @@
# Bedienungsanleitung Führungsbuch Sternwarte Welzheim
## Inhaltsverzeichnis
1. [Anmelden](#1-anmelden)
2. [Grundaufbau der App](#2-grundaufbau-der-app)
3. [Eintrag erfassen (Tab „Eingabe")](#3-eintrag-erfassen-tab-eingabe)
4. [Einträge einsehen und verwalten (Tab „Liste")](#4-einträge-einsehen-und-verwalten-tab-liste)
5. [Jahresstatistik (Tab „Statistik")](#5-jahresstatistik-tab-statistik)
6. [Drucken](#6-drucken)
7. [Administration (nur Admins)](#7-administration-nur-admins)
---
## 1. Anmelden
Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
- **Passwort**: individuell gesetztes Passwort
Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
---
## 2. Grundaufbau der App
### Kuppel-Auswahl
Oben befinden sich vier Reiter für die vier Kuppeln:
| Reiter | Bedeutung |
|--------|-----------|
| West | West-Kuppel |
| Ost | Ost-Kuppel |
| Süd | Süd-Kuppel |
| Pluto | Pluto-Kuppel |
Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.
### Funktions-Tabs
Unterhalb der Kuppelauswahl gibt es drei Tabs:
| Tab | Funktion |
|-----|----------|
| **Eingabe** | Neuen Eintrag anlegen oder bestehenden bearbeiten |
| **Liste** | Alle Einträge monatsweise ansehen, bearbeiten oder löschen |
| **Statistik** | Jahresübersicht Besucher und Führungen |
---
## 3. Eintrag erfassen (Tab „Eingabe")
### Pflichtfelder
**Art der Führung** Auswahl aus dem Dropdown:
| Kürzel | Bedeutung |
|--------|-----------|
| regulär | Reguläre öffentliche Führung |
| sonder | Sonderführung (für Gruppen, Schulen etc.) |
| sonnen | Sonnenführung |
| privat | Privatführung |
| BEOS | BEO-Sitzung (keine Besucher/Objekte) |
| TD | Treff/Diskussion (keine Besucher/Objekte) |
| Beobachtung | Reine Beobachtung ohne Führung |
| ToT | Teleskop ohne Termin |
| Sonstiges | Sonstige Veranstaltung |
**Datum** Datum der Veranstaltung (Standardwert: heute).
**Startzeit / Endzeit** Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. `8` oder `23`) und das Feld verlassen, werden die Minuten automatisch auf `00` gesetzt.
**Besucher** Anzahl der Besucher (nicht bei BEOS und TD).
### Optionale Felder
**Name / Gruppe** erscheint nur bei Sonderführung; Name der Gruppe oder Person.
**BEOs** beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.
**Beobachtete Objekte** nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:
- Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.
- Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.
- Noch unbekannte Objekte einfach eintippen am Ende der Dropdown-Liste erscheint dann **+ „[Name]" hinzufügen**. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.
- Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.
**Bemerkungen** freier Text, max. 500 Zeichen.
**Wetterdaten** Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. `-5`) können direkt eingegeben werden.
### Eintrag speichern
Schaltfläche **Eintrag speichern** unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.
> Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau.
### Eintrag bearbeiten
Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung **Änderungen speichern** klicken oder mit **Abbrechen** verwerfen.
---
## 4. Einträge einsehen und verwalten (Tab „Liste")
### Werkzeugleiste
Die Werkzeugleiste oben in der Liste enthält in einer Zeile:
- **Monatsnavigation** Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; **Aktueller Monat** springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).
- **Suchfeld** Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.
- **🖨 Drucken** siehe Abschnitt [Drucken](#6-drucken).
### Tabelleninhalt
Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte **fettgedruckt** und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.
### Eintrag bearbeiten
Stift-Symbol ✎ rechts in der Zeile.
### Eintrag löschen
× rechts in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich**.
### Seitennavigation
Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.
---
## 5. Jahresstatistik (Tab „Statistik")
Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.
- **Jahr** oben links änderbar (Eingabefeld).
- Darunter vier Kennzahlen-Kacheln:
- Kumulierte Besucher des Jahres für die gewählte Kuppel
- Führungstage des Jahres für die gewählte Kuppel
- Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)
- Führungstage für die gesamte Sternwarte
- **Grafik** Über diesen Button kann die (bekannte) Statistik-Grafik aufgerufen werden. Sie wird in ein einem gesonderten Fenster angezeigt. Zurück zum Führungsbuch kommt man über den Tab-Wechsel im Browser.
---
## 6. Drucken
Im Tab **Liste**: Schaltfläche **🖨 Drucken** oben rechts in der Liste.
- Es werden **alle Einträge des aktuell gewählten Monats** geladen (nicht nur die angezeigte Seite).
- Die Reihenfolge ist beim Ausdruck **chronologisch** (ältester Eintrag zuerst).
- Navigations- und Aktionselemente werden ausgeblendet; oben erscheint ein Kopfzeile mit Kuppelname und Druckdatum.
- Seitenformat: A4 Hochformat, Rand 1,5 cm.
Im Tab **Statistik**: ebenfalls eine **🖨 Drucken**-Schaltfläche für die Jahresstatistik.
---
## 7. Administration (nur Admins)
Erreichbar über die Schaltfläche **Admin** oben rechts (nur für Benutzer mit Admin-Rolle sichtbar).
Die Admin-Seite hat zwei Tabs:
### Benutzerverwaltung
Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.
**Passwort zurücksetzen**: Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort `welzheim` verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.
### Objektverwaltung
Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken.
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
- **Objekt löschen**: × in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.
+2 -2
View File
@@ -16,9 +16,9 @@ No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Do
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`. Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. Middleware lives in `proxy.ts` (Next.js 16 convention) and exports `middleware` (not `proxy`). **Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `logbuch123` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`. **Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **latin1** — avoid non-ASCII characters in SQL WHERE clauses; use `LIKE 'Ascii%'` prefix patterns instead.
**SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``. **SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``.
-5
View File
@@ -12,9 +12,7 @@ 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
@@ -25,14 +23,11 @@ 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
-32
View File
@@ -34,35 +34,3 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Neue Features (Statistik Grafik Proxy)
- Proxy für das interne Statistik-Portal:
- Neuer Server-seitiger Proxy unter `/api/statistik/grafik` (und Catch-all `/api/statistik/grafik/*`).
- Holt die Statistik-Seite serverseitig mit Basic-Auth und gibt sie an den Browser weiter, damit Zugangsdaten nicht im Client landen.
- Leitet auch CSS/JS/Images und AJAX-POSTs durch den Proxy (weitergeleitete Methoden und Bodies werden erhalten).
- Die HTML-Antwort wird bereinigt und relative URLs so umgeschrieben, dass Assets über die Proxy-URL geladen werden (`<base href="/api/statistik/grafik/">`).
- Environment-Variablen (server-side only):
- `STATISTIK_GRAFIK_URL` — Basis-URL des internen Statistik-Portals (z. B. `https://sternwarte-welzheim.de/intern/statistik`).
- `STATISTIK_GRAFIK_USER` — Benutzername für Basic-Auth.
- `STATISTIK_GRAFIK_PASS` — Passwort für Basic-Auth.
- UI-Änderung:
- Der `Grafik`-Button in der Statistik-Ansicht öffnet jetzt die Statistik-Seite in einem neuen Fenster (`window.open('/api/statistik/grafik', '_blank')`). Die vorherige iframe-Integration wurde entfernt, da Browser (insb. Firefox/Safari) bei Einbettung Probleme mit X-Frame-Options/CSP gemacht haben.
- Middleware / Sicherheit:
- Die Proxy-Route wird in der App-Auth-Middleware erlaubt, so dass der Proxy die Statistik-Seite auch ohne Benutzer-Session laden kann (Zugangskontrolle erfolgt über die serverseitigen Basic-Auth-Variablen).
- Datenbank-Backup:
- Das neue Skript `backup_db.sh` erzeugt ein tägliches MySQL-Dump-Backup im Verzeichnis `DB_BACKUP/`.
- Standardmäßig werden Backups älter als 7 Tage automatisch gelöscht.
- Beispiel-Cron-Eintrag für täglich 02:00 Uhr:
```cron
0 2 * * * cd /pfad/zum/repo && DB_ROOT_PASS=deinrootpass DB_NAME=sternwarte ./backup_db.sh --days 7
```
Hinweis: Speichere sensible Zugangsdaten nicht in Repositories. Setze die drei `STATISTIK_...` Variablen in deiner Deployment-Umgebung (z. B. Docker secrets, CI/CD environment variables oder auf dem Server). Die Proxy-Implementierung entfernt framing-blockierende Header und schiebt relative Asset-Pfade durch den Proxy, um Kompatibilitätsprobleme mit Browsern zu vermeiden.
Test, ob das nun Deployed, wenn auf main gepushed wird.
+16 -119
View File
@@ -5,25 +5,19 @@ import { KUPPELN } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm'; import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList'; import LogbuchList from '@/components/LogbuchList';
import Statistik from '@/components/Statistik';
import Fahrkosten from '@/components/Fahrkosten';
import packageJson from '@/package.json'; import packageJson from '@/package.json';
interface Props { interface Props {
kuerzel: string; kuerzel: string;
beoId: number; beoId: number;
beoName: string; beoName: string;
role: string | null;
} }
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) { export default function MainClient({ kuerzel, beoId, beoName }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West'); const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe'); const [activeTab, setActiveTab] = useState<'eingabe' | 'liste'>('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 version = packageJson.version; const version = packageJson.version;
const buildDate = const buildDate =
@@ -48,52 +42,20 @@ 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-2 px-2 sm:py-4 sm:px-4">
<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-6 bg-[#FFFFDD]">
{/* 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-4 gap-2">
<h1 className="text-xl sm:text-2xl font-bold leading-tight text-gray-900"> <h1 className="text-xl sm:text-2xl font-bold leading-tight">
<span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span> <span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Führungsbuch für {activeKuppel}-Kuppel Logbuch 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') && (
<>
<button
onClick={handleBackup}
disabled={backupState === 'running'}
className={`text-xs sm:text-sm px-2 sm:px-3 py-1.5 rounded-lg text-gray-900 disabled:opacity-50 ${
backupState === 'ok' ? 'bg-green-200' :
backupState === 'error' ? 'bg-red-200' :
'bg-gray-200 hover:bg-gray-300'
}`}
>
{backupState === 'running' ? '⏳ Backup…' : backupState === 'ok' ? '✓ Backup OK' : backupState === 'error' ? '✗ Fehler' : '💾 Backup'}
</button>
<a
href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Admin
</a>
</>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded-lg text-white" 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"
> >
Abmelden Abmelden
</button> </button>
@@ -101,7 +63,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
</div> </div>
{/* Kuppel-Tabs */} {/* Kuppel-Tabs */}
<div className="flex gap-1 mb-3 border-b-2 border-gray-300 overflow-x-auto print:hidden"> <div className="flex gap-1 mb-4 border-b-2 border-gray-300 overflow-x-auto">
{KUPPELN.map((k) => ( {KUPPELN.map((k) => (
<button <button
key={k} key={k}
@@ -118,24 +80,19 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
))} ))}
</div> </div>
{/* Eingabe/Liste/Statistik-Tabs */} {/* Eingabe/Liste-Tabs */}
<div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden"> <div className="flex gap-1 mb-4 border-b border-gray-200">
{(['eingabe', 'liste', 'statistik', 'fahrkosten'] as const) {(['eingabe', 'liste'] as const).map((tab) => (
.filter((tab) => tab !== 'fahrkosten' || role?.includes('admin') || role?.includes('master'))
.map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => { onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }}
setActiveTab(tab);
if (tab === 'eingabe') setEditEntry(null);
}}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${ className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab activeTab === tab
? 'border-[#85B7D7] text-gray-900' ? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'} {tab === 'eingabe' ? 'Eingabe' : 'Liste'}
</button> </button>
))} ))}
</div> </div>
@@ -158,7 +115,6 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
{/* Kompakte Liste — nur auf Desktop sichtbar */} {/* Kompakte Liste — nur auf Desktop sichtbar */}
<div className="hidden lg:block mt-5 border-t-2 border-gray-300 pt-4"> <div className="hidden lg:block mt-5 border-t-2 border-gray-300 pt-4">
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2> <h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2>
<button <button
@@ -172,88 +128,29 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
kuppel={activeKuppel} kuppel={activeKuppel}
refreshKey={refreshKey} refreshKey={refreshKey}
onEdit={handleEdit} onEdit={handleEdit}
currentUserKuerzel={kuerzel}
isAdmin={role?.includes('admin') ?? false}
limit={5} limit={5}
compact compact
/> />
</div> </div>
</div>
</> </>
)} )}
{/* Liste-Tab: vollständige Liste */} {/* Liste-Tab: vollständige Liste */}
{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="hidden print:block mb-4">
<div className="text-lg font-bold">Sternwarte Welzheim Führungsbuch {activeKuppel}-Kuppel</div>
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div>
<LogbuchList <LogbuchList
kuppel={activeKuppel} kuppel={activeKuppel}
refreshKey={refreshKey} refreshKey={refreshKey}
onEdit={handleEdit} onEdit={handleEdit}
currentUserKuerzel={kuerzel} limit={20}
isAdmin={role?.includes('admin') ?? false}
limit={15}
/> />
</div>
)} )}
{/* Statistik-Tab */} <footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4">
{activeTab === 'statistik' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden gap-2">
<span className="text-sm font-semibold text-gray-900">Statistik (alle Kuppeln)</span>
<div className="flex items-center gap-2">
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
<button
onClick={() => window.open(grafikSrc, '_blank')}
className="text-sm px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
📊 Grafik
</button>
</div>
</div>
<Statistik />
</div>
)}
{/* Fahrkosten-Tab */}
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-900">Fahrkostenabrechnung</span>
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
</div>
<Fahrkosten />
</div>
)}
<footer className="mt-6 grid grid-cols-3 items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
<div> <div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline"> <a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
rxf@gmx.de rxf@gmx.de
</a> </a>
</div> </div>
<div className="flex justify-center">
<a
href="/anleitung.html"
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Anleitung
</a>
</div>
<div className="text-right"> <div className="text-right">
v{version} {buildDate} v{version} {buildDate}
</div> </div>
-216
View File
@@ -1,216 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ObjektRow } from './actions';
interface Props {
initialObjekte: ObjektRow[];
}
export default function ObjekteManager({ initialObjekte }: Props) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editKategorie, setEditKategorie] = useState<string>('stern');
const [newName, setNewName] = useState('');
const [newKategorie, setNewKategorie] = useState<string>('stern');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSaveEdit(id: number) {
const trimmed = editName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, kategorie: editKategorie }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Speichern.');
return;
}
setEditingId(null);
router.refresh();
}
async function handleDelete(id: number, name: string) {
if (!confirm(`Objekt „${name}" wirklich löschen?`)) return;
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, { method: 'DELETE' });
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Löschen.');
return;
}
router.refresh();
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, kategorie: newKategorie }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Erstellen.');
return;
}
setNewName('');
router.refresh();
}
return (
<div>
{error && (
<p className="mb-3 text-sm text-red-600 font-medium">{error}</p>
)}
<form onSubmit={handleCreate} className="flex gap-2 mb-4">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neues Objekt…"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
/>
<select
value={newKategorie}
onChange={(e) => setNewKategorie(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
<button
type="submit"
disabled={busy}
className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Hinzufügen
</button>
</form>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Kategorie</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th>
<th className="px-4 py-3 w-36"></th>
</tr>
</thead>
<tbody>
{initialObjekte.map((obj, idx) => (
<tr key={obj.ID} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{obj.ID}</td>
<td className="px-4 py-2">
{editingId === obj.ID ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(obj.ID);
if (e.key === 'Escape') setEditingId(null);
}}
autoFocus
className="w-full px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
/>
) : (
obj.Name
)}
</td>
<td className="px-4 py-2 hidden sm:table-cell">
{editingId === obj.ID ? (
<select
value={editKategorie}
onChange={(e) => setEditKategorie(e.target.value)}
className="px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
>
<option value="stern">Stern</option>
<option value="sonne">Sonne</option>
<option value="stern,sonne">Stern &amp; Sonne</option>
</select>
) : (
<span className="flex gap-1">
{obj.Kategorie.split(',').map((k) => (
<span key={k} className={`text-xs px-2 py-0.5 rounded-full font-medium ${k === 'sonne' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}`}>
{k}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-500 hidden sm:table-cell">
{obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'}
</td>
<td className="px-4 py-2 text-right">
{editingId === obj.ID ? (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleSaveEdit(obj.ID)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 border border-green-300 rounded hover:bg-green-200 disabled:opacity-50"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300 rounded hover:bg-gray-200"
>
Abbrechen
</button>
</span>
) : (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setEditKategorie(obj.Kategorie); setError(''); }}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200 disabled:opacity-50"
>
Bearbeiten
</button>
<button
type="button"
onClick={() => handleDelete(obj.ID, obj.Name)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
Löschen
</button>
</span>
)}
</td>
</tr>
))}
{initialObjekte.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
'use client';
import { useActionState } from 'react';
import { resetPassword } from './actions';
interface Props {
userId: number;
userName: string;
}
export default function ResetButton({ userId, userName }: Props) {
const [state, action, isPending] = useActionState(resetPassword, undefined);
return (
<div>
<form
action={action}
onSubmit={(e) => {
if (!confirm(`Passwort von „${userName}" wirklich zurücksetzen?`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="id" value={userId} />
<button
type="submit"
disabled={isPending}
className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
{isPending ? 'Bitte warten…' : 'Zurücksetzen'}
</button>
</form>
{state?.success && (
<p className="text-xs text-green-700 mt-1 max-w-xs">{state.success}</p>
)}
{state?.error && (
<p className="text-xs text-red-600 mt-1">{state.error}</p>
)}
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
'use server';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export type { BeoUser } from '@/lib/phpdb';
export interface ObjektRow {
ID: number;
Name: string;
LastUsed: string | null;
Kategorie: string;
}
export async function listObjekte(): Promise<ObjektRow[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listObjekteAdmin();
}
export async function listUsers(): Promise<phpdb.BeoUser[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
return phpdb.listUsers();
}
export async function resetPassword(
_prevState: { error?: string; success?: string } | undefined,
formData: FormData
): Promise<{ error?: string; success?: string }> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) {
return { error: 'Keine Berechtigung.' };
}
const id = Number(formData.get('id'));
if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' };
}
await phpdb.resetBeoPassword(id);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
}
-107
View File
@@ -1,107 +0,0 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { listUsers, listObjekte } from './actions';
import ResetButton from './ResetButton';
import ObjekteManager from './ObjekteManager';
type Tab = 'benutzer' | 'objekte';
export default async function AdminPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const session = await getSession();
if (!session) redirect('/login');
if (session.role === null || !session.role.includes('admin')) redirect('/');
const { tab: tabParam } = await searchParams;
const activeTab: Tab = tabParam === 'objekte' ? 'objekte' : 'benutzer';
const [users, objekte] = await Promise.all([
activeTab === 'benutzer' ? listUsers() : Promise.resolve([]),
activeTab === 'objekte' ? listObjekte() : Promise.resolve([]),
]);
const tabs: { id: Tab; label: string }[] = [
{ id: 'benutzer', label: 'Benutzerverwaltung' },
{ id: 'objekte', label: 'Objektverwaltung' },
];
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200">
{tabs.map(({ id, label }) => (
<Link
key={id}
href={`/admin?tab=${id}`}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === id
? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{label}
</Link>
))}
</div>
{/* Benutzerverwaltung */}
{activeTab === 'benutzer' && (
<>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.hasPw ? (
<span className="text-green-700">gesetzt</span>
) : (
<span className="text-amber-600 font-medium">Standard</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
</>
)}
{/* Objektverwaltung */}
{activeTab === 'objekte' && (
<ObjekteManager initialObjekte={objekte} />
)}
</main>
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,12 +0,0 @@
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 });
}
+4 -2
View File
@@ -1,12 +1,14 @@
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 phpdb.getBeos(); const rows = await query(
'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL 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);
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export type { FahrkostenRow } from '@/lib/phpdb';
export async function GET(req: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(req.url);
const ab = searchParams.get('ab');
if (!ab || !/^\d{4}-\d{2}-\d{2}$/.test(ab)) {
return NextResponse.json({ error: 'Parameter ab (YYYY-MM-DD) fehlt' }, { status: 400 });
}
try {
const rows = await phpdb.getFahrkosten(ab);
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/fahrkosten:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+47 -24
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 { triggerBackup } from '@/lib/backup'; import type { SelectedObjekt } from '@/types/logbuch';
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();
@@ -12,24 +12,52 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
try { try {
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, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', { await getPool().execute(
Kuppel, ArtFuehrung, SonderName, Beginn, Ende, `UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, Beginn=?, Ende=?, Besucher=?,
Besucher: Besucher ?? 0, Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=?
beoIds: beoIds ?? [], WHERE ID=?`,
objekte: objekte ?? [], [
Bemerkungen: Bemerkungen ?? null, Kuppel, ArtFuehrung, Beginn, Ende,
Wetter: Wetter ?? null, Besucher ?? 0,
}); Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
logbuchId,
]
);
triggerBackup(); await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]);
return NextResponse.json({ ok: true }); await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]);
} catch (error: unknown) {
if (error instanceof Error) { for (const beoId of (beoIds as number[]) || []) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 }); await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]);
if (error.message.includes('403')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
} }
for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID;
if (!objektId) {
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
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, ObjektName) VALUES (?, ?, ?)',
[logbuchId, objektId, obj.Name]
);
}
return NextResponse.json({ ok: true });
} catch (error) {
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 });
} }
@@ -40,16 +68,11 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { id } = await params; const { id } = await params;
const logbuchId = parseInt(id);
try { try {
await phpdb.deleteLogbuch(logbuchId, session.beoId, session.role ?? ''); await query('DELETE FROM logbuch WHERE ID = ?', [parseInt(id)]);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error: unknown) { } catch (error) {
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 });
} }
+63 -24
View File
@@ -1,23 +1,34 @@
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 { triggerBackup } from '@/lib/backup'; import type { SelectedObjekt } from '@/types/logbuch';
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.WetterTemp, l.WetterFeuchte, l.WetterDruck,' +
' l.created_by, l.created_at,' +
" GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," +
" GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte" +
' 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' +
' WHERE l.Kuppel = ?' +
' GROUP BY l.ID' +
' ORDER BY l.Beginn DESC';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url); const { 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') || '20'), 100);
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
const month = searchParams.get('month') || '';
const order = searchParams.get('order') === 'asc' ? 'asc' : 'desc';
const search = (searchParams.get('search') || '').trim();
try { try {
const result = await phpdb.listLogbuch({ kuppel, limit, offset, month, search, order }); const rows = await query(LIST_SQL + ` LIMIT ${limit}`, [kuppel]);
return NextResponse.json(result); return NextResponse.json(rows);
} 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 });
@@ -30,20 +41,48 @@ export async function POST(request: NextRequest) {
try { try {
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, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const result = await phpdb.createLogbuch({ const pool = getPool();
Kuppel, ArtFuehrung, SonderName, Beginn, Ende, const [result] = await pool.execute(
Besucher: Besucher ?? 0, 'INSERT INTO logbuch (Kuppel, ArtFuehrung, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' +
beoIds: beoIds ?? [], ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
objekte: objekte ?? [], [
Bemerkungen: Bemerkungen ?? null, Kuppel, ArtFuehrung, Beginn, Ende,
Wetter: Wetter ?? null, Besucher ?? 0,
created_by: session.beoId, Bemerkungen?.slice(0, 500) || null,
}); Wetter?.temp ?? null,
Wetter?.feuchte ?? null,
Wetter?.druck ?? null,
session.beoId,
]
) as [{ insertId: number }, unknown];
triggerBackup(); const logbuchId = result.insertId;
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 FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[];
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, ObjektName) VALUES (?, ?, ?)',
[logbuchId, objektId, obj.Name]
);
}
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 });
-40
View File
@@ -1,40 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const VALID = ['stern', 'sonne', 'stern,sonne'];
const kat: string | undefined = VALID.includes(kategorie) ? kategorie : undefined;
const result = await phpdb.updateObjekt(numId, trimmed, kat);
return NextResponse.json(result);
} catch (error) {
console.error('PUT /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
await phpdb.deleteObjekt(numId);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DELETE /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+4 -24
View File
@@ -1,35 +1,15 @@
import { NextRequest, 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(req: NextRequest) { 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 raw = req.nextUrl.searchParams.get('kategorie'); const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100');
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);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
} }
} }
export async function POST(req: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { name, kategorie } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const VALID = ['stern', 'sonne', 'stern,sonne'];
const kat: string = VALID.includes(kategorie) ? kategorie : 'stern';
const result = await phpdb.createObjekt(trimmed, kat);
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('POST /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
@@ -1,18 +0,0 @@
import { grafikProxy } from '../proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
-120
View File
@@ -1,120 +0,0 @@
import { NextResponse } from 'next/server';
const blockedHeaders = new Set([
'x-frame-options',
'content-security-policy',
'content-security-policy-report-only',
'frame-options',
'content-encoding',
'content-length',
'transfer-encoding',
]);
function buildProxyTarget(req: Request, slug?: string[]) {
const upstreamUrl = process.env.STATISTIK_GRAFIK_URL;
if (!upstreamUrl) {
throw new Error('STATISTIK_GRAFIK_URL is not configured');
}
const target = new URL(upstreamUrl);
const pathSuffix = slug?.filter(Boolean).join('/') || '';
if (pathSuffix) {
target.pathname = `${target.pathname.replace(/\/$/, '')}/${pathSuffix}`;
}
target.search = new URL(req.url).search;
return target.toString();
}
function buildAuthHeaders() {
const headers: Record<string, string> = {};
const user = process.env.STATISTIK_GRAFIK_USER;
const pass = process.env.STATISTIK_GRAFIK_PASS;
if (user && pass) {
const token = Buffer.from(`${user}:${pass}`).toString('base64');
headers.Authorization = `Basic ${token}`;
}
return headers;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function rewriteHtml(body: string) {
const proxyBase = '/api/statistik/grafik';
const upstreamOrigin = new URL(process.env.STATISTIK_GRAFIK_URL!).origin;
const escapedProxyBase = escapeRegExp(proxyBase);
const proxyBasePattern = escapedProxyBase.replace(/\//g, '\\/');
return body
.replace(/<meta[^>]*?(?:http-equiv|httpEquiv)\s*=\s*['"]?[^'">\s]+['"]?[^>]*>/ig, (m) => {
if (/x-frame-options/i.test(m)) return '';
if (/content-security-policy/i.test(m)) return '';
if (/frame-ancestors/i.test(m)) return '';
return m;
})
.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBase}/">`)
.replace(new RegExp(`(href|src|action)=(["'])(?!${proxyBasePattern}\\/)\/`, 'gi'), `$1=$2${proxyBase}/`)
.replace(new RegExp(`url\\((['"]?)(?!${proxyBasePattern}\\/)\/`, 'gi'), `url($1${proxyBase}/`)
.replace(new RegExp(escapeRegExp(upstreamOrigin), 'g'), proxyBase);
}
export async function grafikProxy(req: Request, slug?: string[]) {
let targetUrl: string;
try {
targetUrl = buildProxyTarget(req, slug);
} catch (error) {
return new NextResponse((error as Error).message, { status: 500 });
}
try {
const requestHeaders: Record<string, string> = {};
req.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'host' || lower === 'content-length') return;
requestHeaders[key] = value;
});
Object.assign(requestHeaders, buildAuthHeaders());
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer();
const upstream = await fetch(targetUrl, {
method: req.method,
headers: requestHeaders,
body,
cache: 'no-store',
});
const bodyBuf = await upstream.arrayBuffer();
const outHeaders: Record<string, string> = {};
upstream.headers.forEach((value, key) => {
if (!blockedHeaders.has(key.toLowerCase())) {
outHeaders[key] = value;
}
});
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
const isHtmlResponse = contentType.toLowerCase().includes('text/html');
// JS/CSS/font assets that come back as HTML are upstream errors (404, redirect pages).
// Return a 404 so the browser doesn't refuse to execute them as wrong MIME type.
const assetExtension = /\.(js|css|woff2?|ttf|eot|png|jpg|gif|svg|ico)(\?|$)/i;
const requestPath = new URL(req.url).pathname;
if (isHtmlResponse && slug?.length && assetExtension.test(requestPath)) {
return new NextResponse(null, { status: 404 });
}
if (isHtmlResponse) {
const text = Buffer.from(bodyBuf).toString('utf8');
const cleaned = rewriteHtml(text);
outHeaders['content-type'] = 'text/html; charset=utf-8';
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(cleaned, { status: upstream.status, headers: outHeaders });
}
outHeaders['x-frame-options'] = 'ALLOWALL';
return new NextResponse(Buffer.from(bodyBuf), { status: upstream.status, headers: outHeaders });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new NextResponse(`grafikProxy error: ${message}`, { status: 500 });
}
}
-18
View File
@@ -1,18 +0,0 @@
import { grafikProxy } from './proxy';
function getSlugFromRequest(req: Request) {
const url = new URL(req.url);
const prefix = '/api/statistik/grafik';
const path = url.pathname;
if (!path.startsWith(prefix)) return undefined;
const suffix = path.slice(prefix.length);
return suffix.split('/').filter(Boolean);
}
export async function GET(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
export async function POST(req: Request) {
return grafikProxy(req, getSlugFromRequest(req));
}
-19
View File
@@ -1,19 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { searchParams } = new URL(request.url);
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10);
try {
const result = await phpdb.getStatistik(year);
return NextResponse.json(result);
} catch (error) {
console.error('GET /api/statistik:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+3 -8
View File
@@ -4,13 +4,8 @@ import { getSession } from '@/lib/session';
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 });
const temp = Math.round((8 + Math.random() * 15) * 10) / 10;
const res = await fetch('https://stwwetter.fuerst-stuttgart.de/api/weather/latest', { cache: 'no-store' }); const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10;
if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 }); const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
const data = await res.json();
const temp = Math.round(data.temperature * 10) / 10;
const feuchte = Math.round(data.humidity);
const druck = Math.round(data.pressure);
return NextResponse.json({ temp, feuchte, druck }); return NextResponse.json({ temp, feuchte, druck });
} }
+5 -7
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 { updateBeoPassword } from '@/lib/phpdb'; import { query } from '@/lib/db';
export async function changePassword( export async function changePassword(
_prevState: { error: string } | undefined, _prevState: { error: string } | undefined,
@@ -19,16 +19,15 @@ export async function changePassword(
return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' }; return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' };
} }
if (newPassword === (process.env.DEFAULT_PASSWORD ?? 'welzheim')) {
return { error: 'Das Standard-Passwort darf nicht als neues Passwort verwendet werden.' };
}
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return { error: 'Die Passwörter stimmen nicht überein.' }; return { error: 'Die Passwörter stimmen nicht überein.' };
} }
const hashed = await hashPassword(newPassword); const hashed = await hashPassword(newPassword);
await updateBeoPassword(session.beoId, hashed); await query(
'UPDATE beos SET pw = ?, MustChangePassword = 0 WHERE id = ?',
[hashed, session.beoId]
);
await createSession({ await createSession({
kuerzel: session.kuerzel, kuerzel: session.kuerzel,
@@ -36,7 +35,6 @@ export async function changePassword(
beoName: session.beoName, beoName: session.beoName,
mustChangePassword: false, mustChangePassword: false,
isAuthenticated: true, isAuthenticated: true,
role: session.role ?? null,
}); });
redirect('/'); redirect('/');
+2 -2
View File
@@ -10,8 +10,8 @@ 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-[#FFFFDD]">
<h1 className="text-3xl font-bold mb-6">Führungsbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold mb-6">Logbuch 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">
-14
View File
@@ -24,17 +24,3 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
@media print {
@page {
size: A4 portrait;
margin: 1.5cm;
}
body {
background: white;
}
table {
font-size: 0.72rem !important;
width: 95% !important;
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Führungsbuch — Sternwarte Welzheim', title: 'Logbuch — Sternwarte Welzheim',
description: 'Führungsbuch für die Sternwarte Welzheim', description: 'Logbuch für die Sternwarte Welzheim',
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
+6 -21
View File
@@ -1,49 +1,34 @@
'use server'; 'use server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { verifyCredentials, getBeoDisplayName } from '@/lib/auth'; import { verifyCredentials, getBeoDisplayName } from '@/lib/auth';
import { createSession } from '@/lib/session'; import { createSession } from '@/lib/session';
import { checkRateLimit } from '@/lib/ratelimit';
export async function login( export async function login(
_prevState: { error: string } | undefined, _prevState: { error: string } | undefined,
formData: FormData formData: FormData
): Promise<{ error: string }> { ): Promise<{ error: string }> {
const headersList = await headers(); const kuerzel = (formData.get('username') as string)?.trim();
const ip =
headersList.get('x-forwarded-for')?.split(',')[0].trim() ??
headersList.get('x-real-ip') ??
'unknown';
const { allowed, remainingMs } = checkRateLimit(ip);
if (!allowed) {
const minutes = Math.ceil(remainingMs / 60000);
return { error: `Zu viele Anmeldeversuche. Bitte ${minutes} Minute${minutes !== 1 ? 'n' : ''} warten.` };
}
const login = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string; const password = formData.get('password') as string;
if (!login || !password) { if (!kuerzel || !password) {
return { error: 'Bitte Kürzel/Nachname und Passwort eingeben.' }; return { error: 'Bitte Kürzel und Passwort eingeben.' };
} }
const result = await verifyCredentials(login, password); const result = await verifyCredentials(kuerzel, password);
if (!result || !result.valid) { if (!result || !result.valid) {
return { error: 'Ungültiger Benutzername oder Passwort.' }; return { error: 'Ungültiges Kürzel oder Passwort.' };
} }
const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw; const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw;
await createSession({ await createSession({
kuerzel: result.beo.kürzel ?? login, kuerzel: result.beo.kürzel ?? kuerzel,
beoId: result.beo.id, beoId: result.beo.id,
beoName: getBeoDisplayName(result.beo), beoName: getBeoDisplayName(result.beo),
mustChangePassword: mustChange, mustChangePassword: mustChange,
isAuthenticated: true, isAuthenticated: true,
role: result.beo.role ?? null,
}); });
if (mustChange) { if (mustChange) {
+4 -4
View File
@@ -15,9 +15,9 @@ export default function LoginPage() {
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-[#FFFFDD]">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Führungsbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
</div> </div>
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
@@ -27,7 +27,7 @@ export default function LoginPage() {
<form action={loginAction} className="space-y-5"> <form action={loginAction} className="space-y-5">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Kürzel oder Nachname Kürzel
</label> </label>
<input <input
id="username" id="username"
@@ -36,7 +36,7 @@ export default function LoginPage() {
required required
autoComplete="off" autoComplete="off"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm" className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Kürzel oder Nachname" placeholder="Kürzel"
disabled={isPending} disabled={isPending}
/> />
</div> </div>
-1
View File
@@ -11,7 +11,6 @@ export default async function HomePage() {
kuerzel={session.kuerzel} kuerzel={session.kuerzel}
beoId={session.beoId} beoId={session.beoId}
beoName={session.beoName} beoName={session.beoName}
role={session.role ?? null}
/> />
); );
} }
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Tägliches MySQL-Backup für das Logbuch-Projekt.
#
# Verwendung:
# ./backup_db.sh
# DB_ROOT_PASS=... DB_NAME=... ./backup_db.sh
# ./backup_db.sh --days 14 --backup-dir /var/backups/logbuch
#
# Installiert keine Abhängigkeiten. Nutzt den laufenden Container mit dem Namen logbuch_mysql.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_DIR_DEFAULT="$SCRIPT_DIR/DB_BACKUP"
CONTAINER_DEFAULT="logbuch_mysql"
KEEP_DAYS_DEFAULT=7
ROOT_PASS="${DB_ROOT_PASS:-${MYSQL_ROOT_PASSWORD:-}}"
DB_NAME="${DB_NAME:-${MYSQL_DATABASE:-}}"
BACKUP_DIR="$BACKUP_DIR_DEFAULT"
CONTAINER="$CONTAINER_DEFAULT"
KEEP_DAYS="$KEEP_DAYS_DEFAULT"
REMOTE=""
REMOTE_RETENTION=""
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--backup-dir DIR Backup-Verzeichnis (default: $BACKUP_DIR_DEFAULT)
--container NAME MySQL-Container-Name (default: $CONTAINER_DEFAULT)
--days N Aufbewahrungszeitraum in Tagen (default: $KEEP_DAYS_DEFAULT)
--db-name NAME Datenbankname (default: DB_NAME oder MYSQL_DATABASE env)
--root-pass PASS MySQL root Passwort (default: DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD env)
--remote user@host:/path Optional: kopiere Backup per scp auf Remote-Host
--remote-retention N Optional: Aufbewahrungstage auf Remote (default: same as --days)
-h, --help Diese Hilfe anzeigen
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backup-dir)
BACKUP_DIR="$2"
shift 2
;;
--container)
CONTAINER="$2"
shift 2
;;
--days)
KEEP_DAYS="$2"
shift 2
;;
--db-name)
DB_NAME="$2"
shift 2
;;
--root-pass)
ROOT_PASS="$2"
shift 2
;;
--remote)
REMOTE="$2"
shift 2
;;
--remote-retention)
REMOTE_RETENTION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unbekannte Option: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$ROOT_PASS" ]]; then
echo "Fehler: MySQL root Passwort ist nicht gesetzt. Setze DB_ROOT_PASS oder MYSQL_ROOT_PASSWORD." >&2
exit 1
fi
if [[ -z "$DB_NAME" ]]; then
echo "Fehler: Datenbankname ist nicht gesetzt. Setze DB_NAME oder MYSQL_DATABASE." >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_$TIMESTAMP.sql.gz"
echo "Erstelle Backup: $BACKUP_FILE"
docker exec "$CONTAINER" mysqldump --single-transaction --quick --lock-tables=false \
--triggers --routines --events --set-gtid-purged=OFF -uroot -p"$ROOT_PASS" "$DB_NAME" | gzip > "$BACKUP_FILE"
if [[ ! -s "$BACKUP_FILE" ]]; then
echo "Fehler: Backup konnte nicht erstellt werden." >&2
exit 1
fi
echo "Backup erfolgreich erstellt: $BACKUP_FILE"
# Optional: upload to remote host (format: user@host:/path)
if [[ -n "$REMOTE" ]]; then
echo "Upload nach Remote: $REMOTE"
if [[ -z "$REMOTE_RETENTION" ]]; then
REMOTE_RETENTION="$KEEP_DAYS"
fi
if [[ "$REMOTE" == *":"* ]]; then
REMOTE_HOSTPART="${REMOTE%%:*}"
REMOTE_PATH="${REMOTE#*:}"
if [[ -z "$REMOTE_PATH" ]]; then
REMOTE_PATH="."
fi
# Verzeichnis auf Remote anlegen, dann Datei kopieren
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "mkdir -p \"$REMOTE_PATH\""
if ! scp -q "$BACKUP_FILE" "${REMOTE_HOSTPART}:${REMOTE_PATH}/"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
echo "Auf Remote $REMOTE_HOSTPART: entferne Backups älter als $REMOTE_RETENTION Tage in $REMOTE_PATH"
ssh -o BatchMode=yes "$REMOTE_HOSTPART" "find \"$REMOTE_PATH\" -maxdepth 1 -type f -name '${DB_NAME}_*.sql.gz' -mtime +$REMOTE_RETENTION -print -delete" || true
fi
else
if ! scp -q "$BACKUP_FILE" "$REMOTE"; then
echo "Warnung: Hochladen nach Remote fehlgeschlagen." >&2
else
echo "Upload erfolgreich."
fi
fi
fi
echo "Lösche Backups älter als $KEEP_DAYS Tage im Verzeichnis $BACKUP_DIR"
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${DB_NAME}_*.sql.gz" -mtime +"$KEEP_DAYS" -print -delete || true
echo "Fertig."
+2 -2
View File
@@ -1,5 +1,5 @@
Erstelle ein Führungsbuch für die Sternwarte in Welzheim. Erstelle ein Logbuch für die Sternwarte in Welzheim.
+ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Führungsbuch. 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 Logbuch. 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
+2 -4
View File
@@ -13,10 +13,9 @@ interface Props {
placeholder: string; placeholder: string;
onChange: (value: string) => void; onChange: (value: string) => void;
keepOpen?: boolean; keepOpen?: boolean;
id?: string;
} }
export default function CustomSelect({ options, placeholder, onChange, keepOpen = false, id }: Props) { export default function CustomSelect({ options, placeholder, onChange, keepOpen = false }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -38,10 +37,9 @@ export default function CustomSelect({ options, placeholder, onChange, keepOpen
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
<button <button
id={id}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none" className="w-full flex items-center justify-between px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-900 focus:border-blue-500 focus:outline-none"
> >
<span>{placeholder}</span> <span>{placeholder}</span>
<svg <svg
-241
View File
@@ -1,241 +0,0 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
interface Props {
value: string; // "YYYY-MM-DD"
onChange: (value: string) => void;
className?: string;
}
const MONTH_NAMES = [
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
const WEEKDAY_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function monthKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}
function monthLabel(ym: string): string {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
return `${MONTH_NAMES[m - 1]} ${y}`;
}
function shiftMonth(ym: string, delta: number): string {
const [ys, ms] = ym.split('-');
const d = new Date(Number(ys), Number(ms) - 1 + delta, 1);
return monthKey(d);
}
function parseISODate(v: string): Date | null {
if (!isValidDateString(v)) return null;
const [ys, ms, ds] = v.split('-');
return new Date(Number(ys), Number(ms) - 1, Number(ds));
}
function buildMonthGrid(ym: string): Array<number | null> {
const [ys, ms] = ym.split('-');
const y = Number(ys);
const m = Number(ms);
const first = new Date(y, m - 1, 1);
const days = new Date(y, m, 0).getDate();
const mondayStartOffset = (first.getDay() + 6) % 7;
const cells: Array<number | null> = Array(mondayStartOffset).fill(null);
for (let d = 1; d <= days; d += 1) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
function isValidDateString(v: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
const [ys, ms, ds] = v.split('-');
const y = Number(ys);
const m = Number(ms);
const d = Number(ds);
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
function normalize(v: string): string {
const digits = v.replace(/\D/g, '').slice(0, 8);
const y = digits.slice(0, 4);
const m = digits.slice(4, 6);
const d = digits.slice(6, 8);
if (digits.length <= 4) return y;
if (digits.length <= 6) return `${y}-${m}`;
return `${y}-${m}-${d}`;
}
export default function DateInput({ value, onChange, className = '' }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
const [open, setOpen] = useState(false);
const [viewMonth, setViewMonth] = useState(monthKey(new Date()));
const wrapperRef = useRef<HTMLDivElement>(null);
const selectedDate = useMemo(() => parseISODate(value), [value]);
const dayCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
useEffect(() => {
setLocal(value);
setError(false);
}, [value]);
useEffect(() => {
const date = parseISODate(value);
if (date) setViewMonth(monthKey(date));
}, [value]);
useEffect(() => {
function onDocMouseDown(ev: MouseEvent) {
if (!wrapperRef.current) return;
if (!wrapperRef.current.contains(ev.target as Node)) setOpen(false);
}
function onDocKeyDown(ev: KeyboardEvent) {
if (ev.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onDocKeyDown);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onDocKeyDown);
};
}, []);
function handleChange(raw: string) {
setError(false);
setLocal(normalize(raw));
}
function handleBlur() {
if (local === '') {
setLocal(value);
setError(false);
return;
}
if (isValidDateString(local)) {
setError(false);
onChange(local);
return;
}
setError(true);
}
function selectDay(day: number) {
const [ys, ms] = viewMonth.split('-');
const iso = `${ys}-${ms}-${pad(day)}`;
setLocal(iso);
setError(false);
onChange(iso);
setOpen(false);
}
function isSelected(day: number): boolean {
if (!selectedDate) return false;
const [ys, ms] = viewMonth.split('-').map(Number);
return selectedDate.getFullYear() === ys && selectedDate.getMonth() === ms - 1 && selectedDate.getDate() === day;
}
return (
<div ref={wrapperRef} className={`relative ${className}`}>
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
value={local}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder="YYYY-MM-DD"
maxLength={10}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
}`}
/>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-xs text-gray-700 hover:bg-gray-50"
aria-label="Kalender oeffnen"
>
Kalender
</button>
</div>
{open && (
<div className="absolute left-0 top-full mt-1 z-20 w-64 border-2 border-gray-300 rounded-lg bg-white shadow-lg p-2">
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, -1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Vorheriger Monat"
>
{'<'}
</button>
<div className="text-sm font-medium">{monthLabel(viewMonth)}</div>
<button
type="button"
onClick={() => setViewMonth((m) => shiftMonth(m, 1))}
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
aria-label="Naechster Monat"
>
{'>'}
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{WEEKDAY_SHORT.map((wd) => (
<div key={wd} className="text-[11px] text-center text-gray-500 py-1">
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{dayCells.map((day, idx) => (
<button
key={`${viewMonth}-${idx}`}
type="button"
disabled={day === null}
onClick={() => day !== null && selectDay(day)}
className={`h-8 rounded text-sm ${
day === null
? 'text-transparent cursor-default'
: isSelected(day)
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-gray-100 text-gray-800'
}`}
>
{day ?? '-'}
</button>
))}
</div>
</div>
)}
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungueltiges Datum (YYYY-MM-DD)
</p>
)}
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
interface FahrkostenRow {
ID: number;
Kuerzel: string;
Name: string;
Anzahl: number;
}
const SATZ = Number(process.env.NEXT_PUBLIC_FAHRKOSTEN_SATZ ?? 15);
function defaultAb(): string {
const d = new Date();
return `${d.getFullYear()}-01-01`;
}
export default function Fahrkosten() {
const [ab, setAb] = useState(defaultAb);
const [rows, setRows] = useState<FahrkostenRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
fetch(`/api/fahrkosten?ab=${ab}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: FahrkostenRow[]) => { if (!cancelled) { setRows(d); setLoading(false); } })
.catch(() => { if (!cancelled) { setError('Fehler beim Laden.'); setLoading(false); } });
return () => { cancelled = true; };
}, [ab]);
const gesamt = rows ? rows.reduce((s, r) => s + r.Anzahl * SATZ, 0) : 0;
const thCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-left whitespace-nowrap text-gray-900';
const thNarrowCls = `${thCls} w-16`;
const tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const tdNarrowCls = `${tdCls} w-16`;
const tdNumCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums w-20 text-gray-900';
const abFormatted = new Date(ab + 'T00:00:00').toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
return (
<div className="space-y-4 print:pl-[1.5cm]">
<div className="flex items-center gap-3 print:hidden">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">Ab Datum</label>
<input
type="date"
value={ab}
onChange={(e) => setAb(e.target.value)}
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Druckkopf */}
<div className="hidden print:block mb-2">
<div className="text-lg font-bold">Sternwarte Welzheim Fahrkostenabrechnung</div>
<div className="text-sm text-gray-600">Führungen ab <strong>{abFormatted}</strong> · {SATZ} pro Führung</div>
<div className="text-xs text-gray-400 mt-0.5">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div>
{loading && <div className="text-gray-500 text-sm py-4">Lade...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && rows && (
<>
{rows.length === 0 ? (
<div className="text-gray-500 text-sm py-4">Keine Führungen in diesem Zeitraum gefunden.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className={thCls}>Name</th>
<th className={thNarrowCls}>Kürzel</th>
<th className={`${thNarrowCls} text-right`}>Führungen</th>
<th className={`${thCls} text-right`}>Fahrkosten</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.ID} className="hover:bg-gray-50 print:hover:bg-transparent">
<td className={tdCls}>{r.Name}</td>
<td className={tdNarrowCls}>{r.Kuerzel}</td>
<td className={tdNumCls}>{r.Anzahl}</td>
<td className={tdNumCls}>{(r.Anzahl * SATZ).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className={`${tdCls} font-semibold`} colSpan={2}>Gesamt</td>
<td className={`${tdNumCls} font-semibold`}>{rows.reduce((s, r) => s + r.Anzahl, 0)}</td>
<td className={`${tdNumCls} font-semibold`}>{gesamt.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
</tfoot>
</table>
</div>
)}
</>
)}
</div>
);
}
+106 -159
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN, ARTEN_MAP, artLabel } from '@/types/logbuch'; import { ARTEN, ARTEN_MAP } from '@/types/logbuch';
import BeoSelector from './BeoSelector'; import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector'; import ObjektSelector from './ObjektSelector';
import CustomSelect from './CustomSelect'; import CustomSelect from './CustomSelect';
import TimeInput from './TimeInput'; import TimePicker5 from './TimePicker5';
interface Props { interface Props {
kuppel: Kuppel; kuppel: Kuppel;
@@ -20,18 +20,39 @@ function toLocalDatetimeValue(isoOrDatetime: string): string {
return isoOrDatetime.slice(0, 16); return isoOrDatetime.slice(0, 16);
} }
function todayDate(): string { function snapTo5(value: string): string {
const now = new Date(); if (!value) return value;
// Fix 4-digit years that are actually < 100 (e.g. "0024" → "2024")
const fixed = value.replace(/^(\d{4})(-.+)$/, (_, y, rest) => {
const year = parseInt(y, 10);
return (year < 100 ? String(year + 2000) : y) + rest;
});
const d = new Date(fixed);
if (isNaN(d.getTime())) return value;
d.setMinutes(Math.round(d.getMinutes() / 5) * 5);
d.setSeconds(0);
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
function nowRounded5(): string { function snapTimeTo5(time: string): string {
if (!time) return time;
const [hStr, mStr] = time.split(':');
const h = parseInt(hStr, 10);
const m = parseInt(mStr, 10);
if (isNaN(h) || isNaN(m)) return time;
const snappedM = Math.round(m / 5) * 5;
const finalH = snappedM >= 60 ? (h + 1) % 24 : h;
const finalM = snappedM >= 60 ? 0 : snappedM;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(finalH)}:${pad(finalM)}`;
}
function nowLocalDatetime(): string {
const now = new Date(); const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const total = now.getHours() * 60 + now.getMinutes(); const raw = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
const rounded = Math.ceil(total / 5) * 5; return snapTo5(raw);
return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`;
} }
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD']; const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
@@ -39,34 +60,17 @@ const SONNE_ART: ArtFuehrung = 'SonF';
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) { export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF'); const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF');
const [beginn, setBeginn] = useState(todayDate()); const [beginn, setBeginn] = useState(nowLocalDatetime());
const [ende, setEnde] = useState(todayDate() + 'T' + nowRounded5()); const [ende, setEnde] = useState(nowLocalDatetime());
const [besucher, setBesucher] = useState<number | ''>(''); const [besucher, setBesucher] = useState<number | ''>('');
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]); const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]); const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState(''); const [bemerkungen, setBemerkungen] = useState('');
const [sonderName, setSonderName] = useState(''); const [wetter, setWetter] = useState<Wetter | null>(null);
const [wetter, setWetter] = useState<Wetter>({ temp: 0, feuchte: 0, druck: 0 });
const [tempRaw, setTempRaw] = useState('0');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const editEntryRef = useRef(editEntry);
editEntryRef.current = editEntry;
const formRef = useRef<HTMLFormElement>(null);
function focusNext(current: HTMLElement) {
if (!formRef.current) return;
const fields = Array.from(
formRef.current.querySelectorAll<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), #art-select'
)
);
const idx = fields.indexOf(current);
if (idx >= 0) fields[(idx + 1) % fields.length]?.focus();
}
const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const isSonne = artFuehrung === SONNE_ART; const isSonne = artFuehrung === SONNE_ART;
@@ -74,12 +78,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
useEffect(() => { useEffect(() => {
fetch('/api/wetter') fetch('/api/wetter')
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((w: Wetter) => { .then(setWetter)
if (!editEntryRef.current) {
setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) });
setTempRaw(String(w.temp));
}
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -90,21 +89,21 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setEnde(toLocalDatetimeValue(editEntry.Ende)); setEnde(toLocalDatetimeValue(editEntry.Ende));
setBesucher(editEntry.Besucher ?? ''); setBesucher(editEntry.Besucher ?? '');
setBemerkungen(editEntry.Bemerkungen ?? ''); setBemerkungen(editEntry.Bemerkungen ?? '');
setSonderName(editEntry.SonderName ?? '');
if (editEntry.WetterTemp !== null) { if (editEntry.WetterTemp !== null) {
const t = editEntry.WetterTemp ?? 0; setWetter({
setWetter({ temp: t, feuchte: Math.round(editEntry.WetterFeuchte ?? 0), druck: Math.round(editEntry.WetterDruck ?? 0) }); temp: editEntry.WetterTemp ?? 0,
setTempRaw(String(t)); feuchte: editEntry.WetterFeuchte ?? 0,
druck: editEntry.WetterDruck ?? 0,
});
} }
} else { } else {
setArtFuehrung('RF'); setArtFuehrung('RF');
setBeginn(todayDate()); setBeginn(nowLocalDatetime());
setEnde(todayDate() + 'T' + nowRounded5()); setEnde(nowLocalDatetime());
setBesucher(0); setBesucher(0);
setBeos([currentUserBeo]); setBeos([currentUserBeo]);
setObjekte([]); setObjekte([]);
setBemerkungen(''); setBemerkungen('');
setSonderName('');
setBesucher(''); setBesucher('');
} }
}, [editEntry, currentUserBeo]); }, [editEntry, currentUserBeo]);
@@ -120,11 +119,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {}); .catch(() => {});
} }
if (editEntry && editEntry.Objekte) { if (editEntry && editEntry.Objekte) {
const allNames = editEntry.Objekte.split(', ').map((n) => n.trim()); const names = editEntry.Objekte.split(', ').map((n) => n.trim());
const isSonneEntry = editEntry.ArtFuehrung === SONNE_ART; fetch('/api/objekte')
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) => {
@@ -137,45 +133,31 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
} }
}, [editEntry]); }, [editEntry]);
// Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung // Objekte-Vorauswahl je nach Art der Führung
useEffect(() => { useEffect(() => {
if (artFuehrung === SONNE_ART) {
setObjekte([{ ID: null, Name: 'Sonne' }]);
} else if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setObjekte([]); setObjekte([]);
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setBesucher('');
} }
}, [artFuehrung]); }, [artFuehrung]);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSaving(true);
setError(''); setError('');
setSuccess(false); setSuccess(false);
if (showBesucher && besucher === '') {
setError('Bitte Besucherzahl eingeben.');
return;
}
if (!beginn.slice(11, 16)) {
setError('Bitte Startzeit eingeben.');
return;
}
if (beginn === ende) {
setError('Start- und Endzeit sind identisch.');
return;
}
setSaving(true);
const body = { const body = {
Kuppel: kuppel, Kuppel: kuppel,
ArtFuehrung: artFuehrung, ArtFuehrung: artFuehrung,
SonderName: artFuehrung === 'SF' ? sonderName : null,
Beginn: beginn, Beginn: beginn,
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 ? (isSonne ? [{ ID: null, Name: 'Sonne' }, ...objekte] : objekte) : [], objekte: showObjekte ? objekte : [],
Bemerkungen: bemerkungen, Bemerkungen: bemerkungen,
Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 }, Wetter: wetter,
}; };
const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch'; const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch';
@@ -191,8 +173,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setSuccess(true); setSuccess(true);
setTimeout(() => setSuccess(false), 5000); setTimeout(() => setSuccess(false), 5000);
if (!editEntry) { if (!editEntry) {
setBeginn(todayDate()); setBeginn(nowLocalDatetime());
setEnde(todayDate() + 'T' + nowRounded5()); setEnde(nowLocalDatetime());
setBesucher(0); setBesucher(0);
setBeos([currentUserBeo]); setBeos([currentUserBeo]);
setObjekte([]); setObjekte([]);
@@ -206,36 +188,27 @@ 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-gray-900 text-sm focus:border-blue-500 focus:outline-none'; const inputCls = 'w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none';
const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5'; const labelCls = 'block text-sm font-medium text-gray-700 mb-0.5';
return ( return (
<form <form onSubmit={handleSubmit} className="space-y-3 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-4 bg-white">
ref={formRef}
onSubmit={handleSubmit}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
const tag = (e.target as HTMLElement).tagName;
if (tag === 'BUTTON' || tag === 'TEXTAREA') return;
e.preventDefault();
focusNext(e.target as HTMLElement);
}}
className="space-y-2 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-3 bg-white"
>
{/* Art der Führung / Datum / Startzeit / Endzeit / Besucher — eine Zeile */} {/* Art der Führung — volle Breite */}
<div className="flex flex-wrap gap-3 items-end"> <div>
<div className="flex-1 min-w-[160px]">
<label className={labelCls}>Art der Führung</label> <label className={labelCls}>Art der Führung</label>
<CustomSelect <CustomSelect
id="art-select" placeholder={`${artFuehrung}${ARTEN_MAP[artFuehrung]}`}
placeholder={artLabel(artFuehrung)} options={ARTEN.map((a) => ({ value: a, label: `${a}${ARTEN_MAP[a]}` }))}
options={ARTEN.map((a) => ({ value: a, label: artLabel(a) }))}
onChange={(v) => setArtFuehrung(v as ArtFuehrung)} onChange={(v) => setArtFuehrung(v as ArtFuehrung)}
/> />
</div> </div>
<div className="shrink-0">
<label className={labelCls}>Datum</label> {/* Beginn / Ende / Besucher */}
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<label className={labelCls}>Beginn</label>
<div className="flex gap-2">
<input <input
type="date" type="date"
value={beginn.slice(0, 10)} value={beginn.slice(0, 10)}
@@ -245,28 +218,37 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00')); setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
}} }}
required required
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none" className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/> />
</div> <TimePicker5
<div className="shrink-0">
<label className={labelCls}>Startzeit</label>
<TimeInput
value={beginn.slice(11, 16)} value={beginn.slice(11, 16)}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)} onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
autoFocus
className="w-24" className="w-24"
/> />
</div> </div>
<div className="shrink-0"> </div>
<label className={labelCls}>Endzeit</label> <div className="w-full sm:flex-1">
<TimeInput <label className={labelCls}>Ende</label>
<div className="flex gap-2">
<input
type="date"
value={ende.slice(0, 10)}
onChange={(e) => {
if (!e.target.value) return;
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
}}
required
className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
<TimePicker5
value={ende.slice(11, 16)} value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)} onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
className="w-24" className="w-24"
/> />
</div> </div>
</div>
{showBesucher && ( {showBesucher && (
<div className="shrink-0"> <div className="sm:shrink-0">
<label className={labelCls}>Besucher</label> <label className={labelCls}>Besucher</label>
<input <input
type="number" type="number"
@@ -274,27 +256,12 @@ 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-gray-900 text-sm focus:border-blue-500 focus:outline-none" className="w-20 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
)} )}
</div> </div>
{/* Name/Gruppe bei Sonderführung */}
{artFuehrung === 'SF' && (
<div>
<label className={labelCls}>Name / Gruppe</label>
<input
type="text"
value={sonderName}
onChange={(e) => setSonderName(e.target.value.slice(0, 200))}
maxLength={200}
placeholder="Name oder Gruppe der Sonderführung"
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
)}
{/* BEOs */} {/* BEOs */}
<div> <div>
<label className={labelCls}>BEOs</label> <label className={labelCls}>BEOs</label>
@@ -305,12 +272,16 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{showObjekte && ( {showObjekte && (
<div> <div>
<label className={labelCls}>Beobachtete Objekte</label> <label className={labelCls}>Beobachtete Objekte</label>
<ObjektSelector {isSonne ? (
selected={objekte} <div className="flex items-center gap-2">
onChange={setObjekte} <span className="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1.5 rounded-full">
kategorie={isSonne ? 'sonne' : 'stern'} Sonne
fixedItems={isSonne ? [{ ID: null, Name: 'Sonne' }] : []} </span>
/> <span className="text-xs text-gray-500">(bei Sonnenführung fest vorgegeben)</span>
</div>
) : (
<ObjektSelector selected={objekte} onChange={setObjekte} />
)}
</div> </div>
)} )}
@@ -324,46 +295,22 @@ 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-gray-900 text-sm focus:border-blue-500 focus:outline-none resize-y" className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none resize-y"
placeholder="Freier Text (max. 500 Zeichen)" placeholder="Freier Text (max. 500 Zeichen)"
/> />
</div> </div>
{/* Wetter */} {/* Wetter */}
{wetter && (
<div> <div>
<div className="grid grid-cols-3"> <label className={labelCls}>Wetter (aktuell)</label>
<div> <div className="flex flex-wrap gap-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label> <span>🌡 {wetter.temp} °C</span>
<input <span>💧 {wetter.feuchte} %</span>
type="text" <span>🌬 {wetter.druck} hPa</span>
inputMode="decimal"
value={tempRaw}
onChange={(e) => setTempRaw(e.target.value)}
className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex flex-col items-center">
<label className="block text-xs text-gray-500 mb-0.5">Feuchte (%)</label>
<input
type="number"
value={wetter.feuchte}
onChange={(e) => setWetter({ ...wetter, feuchte: parseInt(e.target.value) || 0 })}
step="1"
className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex flex-col items-end">
<label className="block text-xs text-gray-500 mb-0.5">Luftdruck (hPa)</label>
<input
type="number"
value={wetter.druck}
onChange={(e) => setWetter({ ...wetter, druck: parseInt(e.target.value) || 0 })}
step="1"
className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
</div> </div>
</div> </div>
)}
{error && ( {error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm"> <div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
@@ -381,7 +328,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="w-full sm:w-auto px-6 py-1 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm" className="w-full sm:w-auto px-6 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-base"
> >
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'} {saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
</button> </button>
@@ -389,7 +336,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<button <button
type="button" type="button"
onClick={onSaved} onClick={onSaved}
className="w-full sm:w-auto px-6 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm" className="w-full sm:w-auto px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-base"
> >
Abbrechen Abbrechen
</button> </button>
+60 -258
View File
@@ -1,321 +1,113 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
interface Props { interface Props {
kuppel: Kuppel; kuppel: Kuppel;
refreshKey: number; refreshKey: number;
onEdit: (entry: LogbuchEintrag) => void; onEdit: (entry: LogbuchEintrag) => void;
currentUserKuerzel: string;
isAdmin?: boolean;
limit?: number; limit?: number;
compact?: boolean; compact?: boolean;
} }
const pad = (n: number) => String(n).padStart(2, '0'); function formatDateTime(dt: string, short = false): string {
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
return `${MONATE[m - 1]} ${y}`;
}
function prevMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 1 ? `${y - 1}-12` : `${y}-${pad(m - 1)}`;
}
function nextMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
}
function formatDate(dt: string, short = false): string {
if (!dt) return ''; if (!dt) return '';
const d = new Date(dt); const d = new Date(dt);
if (isNaN(d.getTime())) return dt; if (isNaN(d.getTime())) return dt;
if (short) return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`; if (short) {
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`; const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}. ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
function formatTime(dt: string): string { export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, compact = false }: Props) {
if (!dt) return '';
const d = new Date(dt);
if (isNaN(d.getTime())) return dt;
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKuerzel, isAdmin = false, limit = 10, compact = false }: Props) {
const [entries, setEntries] = useState<LogbuchEintrag[]>([]); const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(compact ? '' : currentMonth());
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleteErr, setDeleteErr] = useState(''); const [error, setError] = useState('');
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const printPending = useRef(false);
// Derived page: auto-resets to 0 when filter deps change — no separate setState-in-effect needed
const filterKey = `${kuppel}|${refreshKey}|${month}|${activeSearch}`;
const [pageState, setPageState] = useState({ page: 0, key: filterKey });
const page = pageState.key === filterKey ? pageState.page : 0;
// Fetch result tracked by paramsKey — loading/error are derived, not set synchronously in effect
const paramsKey = `${filterKey}|${limit}|${page}`;
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forParams: string } | null>(null);
const fetchError = fetchResult?.forParams === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
const loading = !fetchError && fetchResult?.forParams !== paramsKey;
const error = fetchError || deleteErr;
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setActiveSearch(search.trim()), 300); setLoading(true);
return () => clearTimeout(t); fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}`)
}, [search]);
useEffect(() => {
let cancelled = false;
const offset = page * limit;
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
(activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : ''));
fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { .then((data) => { setEntries(data); setLoading(false); })
if (!cancelled) { .catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
setEntries(data.entries); }, [kuppel, refreshKey, limit]);
setTotal(data.total);
setFetchResult({ ok: true, forParams: paramsKey });
setDeleteErr('');
}
})
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forParams: paramsKey }); });
return () => { cancelled = true; };
}, [kuppel, refreshKey, limit, page, month, activeSearch]);
useEffect(() => {
function onAfterPrint() { setPrintEntries(null); }
window.addEventListener('afterprint', onAfterPrint);
return () => window.removeEventListener('afterprint', onAfterPrint);
}, []);
useEffect(() => {
if (printPending.current && printEntries !== null) {
printPending.current = false;
window.print();
}
}, [printEntries]);
async function handlePrint() {
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=500&offset=0` +
(month ? `&month=${encodeURIComponent(month)}` : '') + '&order=asc';
const data = await fetch(url).then((r) => r.json());
printPending.current = true;
setPrintEntries(data.entries);
}
async function confirmDelete(id: number) { async function confirmDelete(id: number) {
try { try {
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
setEntries((prev) => prev.filter((e) => e.ID !== id)); setEntries((prev) => prev.filter((e) => e.ID !== id));
setTotal((t) => t - 1);
} catch { } catch {
setDeleteErr('Fehler beim Löschen.'); setError('Fehler beim Löschen.');
} finally { } finally {
setDeleteId(null); setDeleteId(null);
} }
} }
const toolbar = !compact && ( if (loading) return <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>;
<div className="flex items-center gap-2 mb-3 print:hidden"> if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
<div if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>;
className="flex items-center gap-1 shrink-0"
style={{ visibility: activeSearch ? 'hidden' : 'visible' }}
>
<button
onClick={() => setMonth((m) => prevMonth(m))}
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5]"
></button>
<input
type="month"
value={month}
max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5] disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<button
onClick={() => setMonth(currentMonth())}
className="text-sm text-blue-600 hover:underline ml-1"
>Aktueller Monat</button>
)}
</div>
<div className="relative flex-1 min-w-0 mx-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche in Bemerkungen, Objekte, BEOs…"
className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
/>
{search ? (
<button
onClick={() => setSearch('')}
aria-label="Suche löschen"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm leading-none"
></button>
) : (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none text-sm">🔍</span>
)}
</div>
<button
onClick={handlePrint}
className="text-sm px-3 py-1.5 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black rounded-lg shrink-0"
>🖨 Drucken</button>
</div>
);
const printHeader = !compact && (
<div className="hidden print:block mb-3 text-sm font-semibold">
Monat: {monthLabel(month)}
</div>
);
const cell = compact const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900' ? 'px-1.5 py-1 border border-gray-200 text-xs'
: 'px-3 py-2 border border-gray-200 text-gray-900'; : 'px-3 py-2 border border-gray-200';
const head = compact const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900' ? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
: 'px-3 py-2 border border-gray-300 text-gray-900'; : 'px-3 py-2 border border-gray-300';
const displayEntries = printEntries ?? entries;
return ( return (
<div> <div>
{toolbar} <div className="overflow-x-auto">
{printHeader}
{loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && <div className="overflow-x-auto">
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}> <table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
<thead> <thead>
<tr className="bg-gray-100 text-left"> <tr className="bg-gray-100 text-left">
<th className={`${head} whitespace-nowrap`}>Datum</th> <th className={`${head} whitespace-nowrap`}>Beginn</th>
{compact ? (
<>
<th className={`${head} whitespace-nowrap`}>Start</th>
<th className={`${head} whitespace-nowrap`}>Ende</th> <th className={`${head} whitespace-nowrap`}>Ende</th>
</>
) : (
<th className={`${head} whitespace-nowrap text-center`}>Zeit</th>
)}
<th className={head}>Art</th> <th className={head}>Art</th>
<th className={`${head} text-center w-10`}>Bes.</th> <th className={`${head} text-center`}>Besucher</th>
<th className={head}>BEOs</th> <th className={head}>BEOs</th>
<th className={head}>Objekte</th> <th className={head}>Objekte</th>
{!compact && <th className={head}>Bemerkungen</th>} {!compact && <th className={head}>Bemerkungen</th>}
{!compact && <th className={head}>Wetter</th>} <th className={`${head} text-center`}>Aktionen</th>
<th className={`${head} text-center print:hidden`}>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{displayEntries.length === 0 ? ( {entries.map((e) => (
<tr>
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
{activeSearch ? `Keine Einträge für „${activeSearch}" gefunden.` : month ? `Keine Einträge für ${monthLabel(month)}.` : 'Keine Einträge vorhanden.'}
</td>
</tr>
) : displayEntries.map((e) => (
<tr key={e.ID} className="hover:bg-gray-50"> <tr key={e.ID} className="hover:bg-gray-50">
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.Beginn, compact)}</td> <td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Beginn, compact)}</td>
{compact ? ( <td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Ende, compact)}</td>
<> <td className={cell}>{e.ArtFuehrung}</td>
<td className={`${cell} whitespace-nowrap`}>{formatTime(e.Beginn)}</td> <td className={`${cell} text-center`}>{e.Besucher}</td>
<td className={`${cell} whitespace-nowrap`}>{formatTime(e.Ende)}</td> <td className={cell}>{e.BEOs || '—'}</td>
</>
) : (
<td className={`${cell} whitespace-nowrap text-center`}>
<div>{formatTime(e.Beginn)}</div>
<div className="text-gray-400 leading-none"></div>
<div>{formatTime(e.Ende)}</div>
</td>
)}
<td className={cell}>
<div>{e.ArtFuehrung}</div>
{e.SonderName && <div className="text-xs text-gray-500">{e.SonderName}</div>}
</td>
<td className={`${cell} text-center`}>{e.Besucher || ''}</td>
<td className={cell}>
{e.BEOs
? (() => {
const beos = e.BEOs.split(', ');
if (e.created_by_kuerzel) {
const idx = beos.indexOf(e.created_by_kuerzel);
if (idx > 0) beos.unshift(beos.splice(idx, 1)[0]);
}
return beos.map((k, i, arr) => (
<span key={k}>
{k === e.created_by_kuerzel ? <strong>{k}</strong> : k}
{i < arr.length - 1 ? ', ' : ''}
</span>
));
})()
: '—'}
</td>
<td className={cell}>{e.Objekte || '—'}</td> <td className={cell}>{e.Objekte || '—'}</td>
{!compact && <td className={cell}>{e.Bemerkungen || ''}</td>}
{!compact && ( {!compact && (
<td className={cell}> <td className={`${cell} max-w-xs`}>
{e.WetterTemp !== null && !(parseFloat(String(e.WetterTemp)) === 0 && parseFloat(String(e.WetterFeuchte ?? 0)) === 0 && parseFloat(String(e.WetterDruck ?? 0)) === 0) && ( <span className="line-clamp-2">{e.Bemerkungen || ''}</span>
<div className="text-xs whitespace-nowrap">
<div>{e.WetterTemp} °C</div>
<div>{Math.round(e.WetterFeuchte ?? 0)} %</div>
<div>{Math.round(e.WetterDruck ?? 0)} hPa</div>
</div>
)}
</td> </td>
)} )}
<td className={`${cell} text-center whitespace-nowrap print:hidden`}> <td className={`${cell} text-center whitespace-nowrap`}>
{(isAdmin || e.BEOs?.split(', ').includes(currentUserKuerzel)) && ( <button
<> onClick={() => onEdit(e)}
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button> className="text-blue-600 hover:text-blue-800 mr-2 font-medium"
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button> >
</>
)} </button>
<button
onClick={() => setDeleteId(e.ID)}
className="text-red-600 hover:text-red-800 font-medium"
>
</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>}
{!compact && total > limit && (
<div className="flex items-center justify-center gap-3 mt-3 print:hidden">
<button
onClick={() => setPageState({ page: page - 1, key: filterKey })}
disabled={page === 0}
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
> Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button
onClick={() => setPageState({ page: page + 1, key: filterKey })}
disabled={(page + 1) * limit >= total}
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
>Weiter </button>
</div> </div>
)}
{deleteId !== null && ( {deleteId !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
@@ -323,8 +115,18 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3> <h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p> <p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button onClick={() => setDeleteId(null)} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm">Abbrechen</button> <button
<button onClick={() => confirmDelete(deleteId)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">Löschen</button> onClick={() => setDeleteId(null)}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm"
>
Abbrechen
</button>
<button
onClick={() => confirmDelete(deleteId)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm"
>
Löschen
</button>
</div> </div>
</div> </div>
</div> </div>
+61 -95
View File
@@ -1,97 +1,51 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch'; import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
import CustomSelect from './CustomSelect';
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, kategorie = 'stern', fixedItems = [] }: Props) { export default function ObjektSelector({ selected, onChange }: Props) {
const [all, setAll] = useState<ObjektOption[]>([]); const [all, setAll] = useState<ObjektOption[]>([]);
const [search, setSearch] = useState(''); const [newName, setNewName] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false); const [showNewInput, setShowNewInput] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
fetch('/api/objekte?kategorie=' + kategorie) fetch('/api/objekte')
.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(() => {
function handleOutside(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [dropdownOpen]);
const fixedNamesLower = new Set(fixedItems.map((o) => o.Name.toLowerCase()));
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase())); const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
const available = all.filter( const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
(o) => !selectedNames.has(o.Name.toLowerCase()) && !fixedNamesLower.has(o.Name.toLowerCase())
);
const filtered = search
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
: available;
const searchTrimmed = search.trim(); function add(value: string) {
const alreadySelected = searchTrimmed !== '' && (selectedNames.has(searchTrimmed.toLowerCase()) || fixedNamesLower.has(searchTrimmed.toLowerCase())); const obj = all.find((o) => o.ID === parseInt(value));
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase()); if (obj && !selectedNames.has(obj.Name.toLowerCase())) {
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
function add(obj: ObjektOption) {
onChange([...selected, { ID: obj.ID, Name: obj.Name }]); onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
setSearch(''); }
inputRef.current?.focus();
} }
function addNew(name: string) { function addNew() {
const trimmed = name.trim(); const name = newName.trim();
if (!trimmed || selectedNames.has(trimmed.toLowerCase()) || fixedNamesLower.has(trimmed.toLowerCase())) return; if (!name || selectedNames.has(name.toLowerCase())) return;
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase()); onChange([...selected, { ID: null, Name: name }]);
if (existing) { setNewName('');
onChange([...selected, { ID: existing.ID, Name: existing.Name }]); setShowNewInput(false);
} else {
onChange([...selected, { ID: null, Name: trimmed }]);
}
setSearch('');
inputRef.current?.focus();
} }
function remove(name: string) { function remove(name: string) {
onChange(selected.filter((o) => o.Name !== name)); onChange(selected.filter((o) => o.Name !== name));
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Enter') return;
e.preventDefault();
if (filtered.length === 1 && !showAddNew) {
add(filtered[0]);
} else if (filtered.length === 0 && searchTrimmed) {
addNew(searchTrimmed);
}
}
return ( 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}
@@ -110,41 +64,53 @@ export default function ObjektSelector({ selected, onChange, kategorie = 'stern'
))} ))}
</div> </div>
<div ref={wrapperRef} className="relative"> <div className="flex gap-2">
<input {available.length > 0 && (
ref={inputRef} <div className="flex-1">
type="text" <CustomSelect
value={search} placeholder="+ Objekte hinzufügen"
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }} options={available.map((o) => ({ value: String(o.ID), label: o.Name }))}
onFocus={() => setDropdownOpen(true)} onChange={add}
onKeyDown={handleKeyDown} keepOpen
placeholder="Objekt suchen oder neu eingeben..."
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/> />
{dropdownOpen && (filtered.length > 0 || showAddNew) && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filtered.map((o) => (
<button
key={o.ID}
type="button"
onClick={() => add(o)}
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
>
{o.Name}
</button>
))}
{showAddNew && (
<button
type="button"
onClick={() => addNew(searchTrimmed)}
className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium"
>
+ &bdquo;{searchTrimmed}&ldquo; hinzufügen
</button>
)}
</div> </div>
)} )}
<button
type="button"
onClick={() => setShowNewInput((v) => !v)}
className="px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-700 hover:bg-gray-50 whitespace-nowrap"
>
+ Neu
</button>
</div> </div>
{showNewInput && (
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
placeholder="Objektname eingeben"
className="flex-1 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none"
autoFocus
/>
<button
type="button"
onClick={addNew}
className="px-4 py-2 bg-green-600 text-white text-base rounded-lg hover:bg-green-700"
>
OK
</button>
<button
type="button"
onClick={() => { setShowNewInput(false); setNewName(''); }}
className="px-4 py-2 bg-gray-200 text-gray-700 text-base rounded-lg hover:bg-gray-300"
>
</button>
</div>
)}
</div> </div>
); );
} }
-172
View File
@@ -1,172 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
interface MonthRow {
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;
}
interface StatsData {
monthly: MonthRow[];
cumulative: number;
tage: number;
year: number;
}
const MONATE = [
'Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember',
];
function n(v: number) {
return v > 0 ? v.toLocaleString('de-DE') : '';
}
export default function Statistik() {
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<StatsData | null>(null);
const [fetchError, setFetchError] = useState<number | null>(null);
const error = fetchError === year ? 'Fehler beim Laden der Statistik.' : '';
const loading = !error && (!data || data.year !== year);
useEffect(() => {
let cancelled = false;
fetch(`/api/statistik?year=${year}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: StatsData) => { if (!cancelled) { setData(d); setFetchError(null); } })
.catch(() => { if (!cancelled) setFetchError(year); });
return () => { cancelled = true; };
}, [year]);
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Statistik...</div>;
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
const rows = data!.monthly;
function col(key: keyof MonthRow): number {
return rows.reduce((s, r) => s + (r[key] as number), 0);
}
const thTop = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-center text-gray-900';
const thSub = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const thDiv = 'px-3 py-2 border border-gray-300 border-l-4 border-l-gray-400 text-xs font-semibold bg-gray-50 whitespace-nowrap text-gray-900';
const td = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums text-gray-900';
const tdDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums text-gray-900';
const tdL = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap text-gray-900';
const tdSum = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
const tdSumDiv = 'px-3 py-2 border border-gray-200 border-l-4 border-l-gray-400 text-sm text-right tabular-nums font-semibold bg-gray-50 text-gray-900';
return (
<div className="space-y-4">
<div className="flex items-center gap-3 print:hidden">
<label className="text-sm font-medium text-gray-700">Jahr</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
min={2000}
max={2100}
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className={thTop} rowSpan={2}>Monat</th>
<th className={thTop} colSpan={6}>Besucher</th>
<th className={`${thTop} border-l-4 border-l-gray-400`} colSpan={7}>Anzahl</th>
</tr>
<tr>
<th className={thSub}>RF</th>
<th className={thSub}>SF</th>
<th className={thSub}>SonF</th>
<th className={thSub}>PrF</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
<th className={thDiv}>Führ.</th>
<th className={thSub}>Beob.</th>
<th className={thSub}>TD</th>
<th className={thSub}>Sonst.</th>
<th className={thSub}>BEOS</th>
<th className={thSub}>ToT</th>
<th className={thSub}>Gesamt</th>
</tr>
</thead>
<tbody>
{MONATE.map((name, idx) => {
const m = idx + 1;
const r = rows.find((row) => row.monat === m);
const hasData = r && r.tageGesamt > 0;
const cls = hasData ? '' : 'text-gray-400';
return (
<tr key={name} className={cls}>
<td className={tdL}>{name}</td>
<td className={td}>{r ? n(r.besucherRF) : ''}</td>
<td className={td}>{r ? n(r.besucherSF) : ''}</td>
<td className={td}>{r ? n(r.besucherSonF) : ''}</td>
<td className={td}>{r ? n(r.besucherPrF) : ''}</td>
<td className={td}>{r ? n(r.besucherToT) : ''}</td>
<td className={`${td} font-semibold`}>{r ? n(r.besucherGesamt) : ''}</td>
<td className={`${tdDiv} font-semibold`}>{r ? n(r.tageFuehrungen) : ''}</td>
<td className={td}>{r ? n(r.tageBeob) : ''}</td>
<td className={td}>{r ? n(r.tageTD) : ''}</td>
<td className={td}>{r ? n(r.tageSonst) : ''}</td>
<td className={td}>{r ? n(r.tageBEOS) : ''}</td>
<td className={td}>{r ? n(r.tagesToT) : ''}</td>
<td className={`${td} font-semibold`}>{r ? n(r.tageGesamt) : ''}</td>
</tr>
);
})}
<tr>
<td className={tdL + ' font-semibold'}>Summe</td>
<td className={tdSum}>{n(col('besucherRF'))}</td>
<td className={tdSum}>{n(col('besucherSF'))}</td>
<td className={tdSum}>{n(col('besucherSonF'))}</td>
<td className={tdSum}>{n(col('besucherPrF'))}</td>
<td className={tdSum}>{n(col('besucherToT'))}</td>
<td className={tdSum}>{n(col('besucherGesamt'))}</td>
<td className={tdSumDiv}>{n(col('tageFuehrungen'))}</td>
<td className={tdSum}>{n(col('tageBeob'))}</td>
<td className={tdSum}>{n(col('tageTD'))}</td>
<td className={tdSum}>{n(col('tageSonst'))}</td>
<td className={tdSum}>{n(col('tageBEOS'))}</td>
<td className={tdSum}>{n(col('tagesToT'))}</td>
<td className={tdSum}>{n(col('tageGesamt'))}</td>
</tr>
</tbody>
</table>
</div>
<div className="grid grid-cols-3 gap-4 w-full max-w-2xl">
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Besucher {year}</div>
<div className="text-2xl font-bold text-gray-900">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
</div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Alle Events {year}</div>
<div className="text-2xl font-bold text-gray-900">{data?.tage ?? 0}</div>
</div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Führungen {year}</div>
<div className="text-2xl font-bold text-gray-900">{col('tageFuehrungen').toLocaleString('de-DE')}</div>
</div>
</div>
</div>
);
}
-90
View File
@@ -1,90 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
interface Props {
value: string; // "HH:MM"
onChange: (value: string) => void;
className?: string;
clearOnFocus?: boolean;
autoFocus?: boolean;
}
function isValid(t: string): boolean {
if (!/^\d{1,2}:\d{2}$/.test(t)) return false;
const [h, m] = t.split(':').map(Number);
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
}
function normalize(t: string): string {
const [h, m] = t.split(':').map(Number);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false, autoFocus = false }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
useEffect(() => {
setLocal(value);
setError(false);
}, [value]);
function handleChange(raw: string) {
setError(false);
// Auto-insert colon after the 2nd digit (only when adding, not deleting)
if (/^\d{2}$/.test(raw) && /^\d$/.test(local)) {
setLocal(raw + ':');
return;
}
setLocal(raw);
}
function handleFocus() {
if (clearOnFocus) {
setLocal('');
setError(false);
}
}
function handleBlur() {
if (local === '') {
setLocal(value);
setError(false);
return;
}
const expanded = /^\d{1,2}:?$/.test(local) ? local.replace(/:$/, '') + ':00' : local;
if (isValid(expanded)) {
const norm = normalize(expanded);
setLocal(norm);
setError(false);
onChange(norm);
} else {
setError(true);
}
}
return (
<div className={`relative ${className}`}>
<input
type="text"
inputMode="numeric"
value={local}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autoFocus}
placeholder="HH:MM"
maxLength={5}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
}`}
/>
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungültig (00:00 23:59)
</p>
)}
</div>
);
}
-33
View File
@@ -1,33 +0,0 @@
services:
logbuch_app:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
environment:
PHP_DB_URL: ${PHP_DB_URL}
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
ports:
- 127.0.0.1:${APP_PORT:-3000}:3000
labels:
- traefik.enable=true
- traefik.http.routers.logbuch.entrypoints=http
- traefik.http.routers.logbuch.rule=Host(`logbuch.fuerst-stuttgart.de`)
- traefik.http.middlewares.logbuch-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.logbuch.middlewares=logbuch-https-redirect
- traefik.http.routers.logbuch-secure.entrypoints=https
- traefik.http.routers.logbuch-secure.rule=Host(`logbuch.fuerst-stuttgart.de`)
- traefik.http.routers.logbuch-secure.tls=true
- traefik.http.routers.logbuch-secure.service=logbuch
- traefik.http.services.logbuch.loadbalancer.server.port=3000
networks:
- proxy
- gitea-internal
networks:
proxy:
name: dockge_default
external: true
gitea-internal:
name: gitea_gitea-internal
external: true
-6
View File
@@ -37,12 +37,6 @@ docker buildx build \
--push \ --push \
. .
echo ">>> Tagge ${IMAGE_NAME} als :latest..."
docker buildx imagetools create \
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
"${FULL_IMAGE}"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Deploy erfolgreich abgeschlossen!" echo "Deploy erfolgreich abgeschlossen!"
+33 -6
View File
@@ -1,21 +1,48 @@
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_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"
extra_hosts: depends_on:
- "host.docker.internal:host-gateway" logbuch_mysql:
condition: service_healthy
networks: networks:
- logbuch_net - logbuch_net
networks: networks:
logbuch_net: logbuch_net:
driver: bridge driver: bridge
volumes:
db_data:
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Erstellt einen SQL-Dump der 5 App-Tabellen aus dem lokalen logbuch_mysql-Container.
# Die Datei logbuch_dump.sql dann auf den Server kopieren und dort server_import.sh ausführen.
set -e
CONTAINER="logbuch_mysql"
DB="sternwarte"
ROOT_PASS="SFluorit"
TABLES="beos objekte logbuch logbuch_beos logbuch_objekte"
OUTFILE="logbuch_dump.sql"
echo "Exportiere Tabellen aus '$CONTAINER'..."
docker exec "$CONTAINER" mysqldump \
-u root -p"$ROOT_PASS" \
--single-transaction \
--no-tablespaces \
"$DB" $TABLES > "$OUTFILE"
echo "Dump gespeichert: $OUTFILE ($(wc -c < "$OUTFILE" | tr -d ' ') Bytes)"
echo ""
echo "Nächste Schritte:"
echo " scp $OUTFILE user@server:/opt/logbuch/"
echo " scp docker-compose.prod.yml user@server:/opt/logbuch/"
echo " scp .env.prod.example user@server:/opt/logbuch/.env.prod"
echo " # Auf dem Server: .env.prod befüllen, dann:"
echo " # docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d"
echo " # ./server_import.sh"
+20 -13
View File
@@ -1,26 +1,33 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { getBeoByKuerzel, getBeoByName } from './phpdb'; import { query } from './db';
export type { Beo } from './phpdb'; export interface Beo {
import type { Beo } from './phpdb'; id: number;
name: string;
vorname: string | null;
kürzel: string | null;
pw: string | null;
MustChangePassword: number;
}
export async function getBeoByLogin(login: string): Promise<Beo | null> { export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const byKuerzel = await getBeoByKuerzel(login); const rows = await query(
if (byKuerzel) return byKuerzel; 'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword FROM beos WHERE `kürzel` = ?',
return getBeoByName(login); [kuerzel]
) as Beo[];
return rows[0] ?? null;
} }
export async function verifyCredentials( export async function verifyCredentials(
login: string, kuerzel: string,
password: string password: string
): Promise<{ beo: Beo; valid: boolean } | null> { ): Promise<{ beo: Beo; valid: boolean } | null> {
const beo = await getBeoByLogin(login); const beo = await getBeoByKuerzel(kuerzel);
if (!beo) return null; if (!beo) return null;
if (!beo.pw) { if (!beo.pw) {
const defaultPw = process.env.DEFAULT_PASSWORD; const valid = password === 'logbuch123';
if (!defaultPw) throw new Error('DEFAULT_PASSWORD Umgebungsvariable ist nicht gesetzt!'); return { beo, valid };
return { beo, valid: password === defaultPw };
} }
const valid = await bcrypt.compare(password, beo.pw); const valid = await bcrypt.compare(password, beo.pw);
@@ -28,7 +35,7 @@ export async function verifyCredentials(
} }
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12); return bcrypt.hash(password, 10);
} }
export function getBeoDisplayName(beo: Beo): string { export function getBeoDisplayName(beo: Beo): string {
-123
View File
@@ -1,123 +0,0 @@
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 */ }
}
}
+28
View File
@@ -0,0 +1,28 @@
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',
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
@@ -1,185 +0,0 @@
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');
}
-38
View File
@@ -1,38 +0,0 @@
// In-memory rate limiter funktioniert pro Prozess (single Docker container).
// Erlaubt MAX_ATTEMPTS Versuche pro IP innerhalb WINDOW_MS Millisekunden.
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000; // 15 Minuten
interface Entry {
count: number;
resetAt: number;
}
const store = new Map<string, Entry>();
// Aufräumen abgelaufener Einträge alle 5 Minuten
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt < now) store.delete(key);
}
}, 5 * 60 * 1000);
export function checkRateLimit(ip: string): { allowed: boolean; remainingMs: number } {
const now = Date.now();
const entry = store.get(ip);
if (!entry || entry.resetAt < now) {
store.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remainingMs: 0 };
}
entry.count += 1;
if (entry.count > MAX_ATTEMPTS) {
return { allowed: false, remainingMs: entry.resetAt - now };
}
return { allowed: true, remainingMs: 0 };
}
+4 -8
View File
@@ -4,11 +4,8 @@ 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;
function getKey(): Uint8Array { const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
const secretKey = process.env.AUTH_SECRET; const key = new TextEncoder().encode(secretKey);
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
return new TextEncoder().encode(secretKey);
}
export interface SessionData { export interface SessionData {
kuerzel: string; kuerzel: string;
@@ -17,7 +14,6 @@ export interface SessionData {
mustChangePassword: boolean; mustChangePassword: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
expiresAt: number; expiresAt: number;
role: string | null;
} }
async function encrypt(payload: SessionData): Promise<string> { async function encrypt(payload: SessionData): Promise<string> {
@@ -25,12 +21,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(getKey()); .sign(key);
} }
async function decrypt(token: string): Promise<SessionData | null> { async function decrypt(token: string): Promise<SessionData | null> {
try { try {
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] }); const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
return payload as unknown as SessionData; return payload as unknown as SessionData;
} catch { } catch {
return null; return null;
+10 -12
View File
@@ -43,7 +43,6 @@ CREATE TABLE `beos` (
`bemerkung` varchar(50) DEFAULT NULL, `bemerkung` varchar(50) DEFAULT NULL,
`pw` varchar(70) DEFAULT NULL, `pw` varchar(70) DEFAULT NULL,
`MustChangePassword` tinyint(1) DEFAULT '1', `MustChangePassword` tinyint(1) DEFAULT '1',
`role` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1; ) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
@@ -54,7 +53,7 @@ CREATE TABLE `beos` (
LOCK TABLES `beos` WRITE; LOCK TABLES `beos` WRITE;
/*!40000 ALTER TABLE `beos` DISABLE KEYS */; /*!40000 ALTER TABLE `beos` DISABLE KEYS */;
INSERT INTO `beos` VALUES (1,'Brückner','Steffen','Brü','Hegelstr. 10','71093','Weil im Schönbuch','brueckner@ccdastro.de',NULL,'070312627550',NULL,NULL,'070312627551',NULL,'m',2,'Mi II,Sa A',NULL,NULL,0,'guide, key'),(2,'Dschida','Hans','HD','Rain 5','73660','Urbach','hansdschida1@gmail.com',NULL,'0718184322','015575359828','07195142599',NULL,NULL,'m',21,'Mi I,Sa C',NULL,'$2b$10$AvNL4FSmTrD6.TfC3D5Y7uD8Erl1fShtpxy9FqajohRkwb6h1F1uS',0,'guide, key'),(5,'Ess','Andrea','Ess','Beethovenweg 8','73630','Remshalden','andrea.ess@t-online.de',NULL,'071512703929',NULL,'07151566486',NULL,NULL,'w',NULL,NULL,NULL,NULL,0,'guide'),(6,'Förnzler','Ulrich','','Ober Str. 12','7ß190','Stuttgart','papa.foernzler@gmx.de',NULL,'07112865188','01778239801',NULL,NULL,NULL,'m',12,NULL,NULL,NULL,0,'key'),(7,'Fürst','Reinhard','rxf','Forststr. 66a','70176','Stuttgart','rexfue@gmail.com',NULL,'07116369409','01713129481',NULL,NULL,'','m',4,'Mo I,Sa C',NULL,'$2b$10$nmpF4s1rgeVF.6o1Nv7nk.OvAlcEJKsrAWeiqgESyBcKtvxw0fZNS',0,'guide, key, admin'),(8,'Gertz','Martin','MG','Buhlstr. 39/1','71384','Weinstadt','martin.gertz@gmx.de','martin.gertz@stihl.de','071519459521','015788298545','07151262545','071512682545',NULL,'m',5,'Sa B','HOBS','$2b$10$OWIMz/AFBURTz428C7CvB.NUXo2OuGfAU3nuGl/S.DESYeKcoIugm',0,'guide, key'),(9,'Meyer-Hamme','Olaf','MH','Eichenweg 29','73650','Winterbach','olaf.meyer-hamme@gmx.de',NULL,'071814808656','015221962790','07181977050',NULL,NULL,'m',22,'Mi II,Sa C',NULL,NULL,0,'guide, key'),(10,'Idler','Rudolf','ID','Stettener Str. 26/1','71394','Kernen','r.idler@freenet.de',NULL,NULL,'01782097963','07119576017',NULL,NULL,'m',11,'Sa A',NULL,NULL,0,'key'),(11,'Nikolaizig','Jörg','JN','Grundweinberge 22','71642','Ludwigsburg','joniko@web.de',NULL,'07141257447','015122894226','071316444249',NULL,NULL,'m',10,'Mo II,Sa A',NULL,'$2b$10$issn3Zd2Jp959ujM61S5gewaQPZzmEb7jSDyqVAMsCTYvCdNpNdkq',0,'guide, key'),(15,'Weishaar','Christoph','CW','Leintelstr. 48','71336','Waiblingen-Bittenfeld','erfinderwerkstatt@t-online.de','c.weishaar@pilz.de ','071462840172',NULL,'07113409216','07113409434',NULL,'m',7,'Mi I,Sa B',NULL,'$2b$10$wSc60txkGL8cvNEZeF8IS.Yzfm8xvM1yJ/EbbRJRI5GkK3ERpkesW',0,'guide, key'),(16,'Zoller','Matthias','Zo','Rosenstr. 49','71063','Sindelfingen','mazoller@gmx.de',NULL,'07031876466','01713752637','071197242618',NULL,NULL,'m',8,'Mo II,Sa A',NULL,NULL,0,'guide, key'),(17,'Keller','Hans-Ulrich','HUK','Planetarium','','Stuttgart','hans-ulrich.keller@stuttgart.de','planetarium@stuttgart.de','07114403350',NULL,'07111629226','07112163912',NULL,'m',1,NULL,NULL,'$2b$10$tyMkggLaVOwZMsetcFExK.kTjOATYWg0zBPp/BGVHy84CaCf7hIfa',0,'guide, key'),(18,'Gräber','Hubert','HHG','Im Brunnengarten 20','73630','Remshalden','hubert.graeber@t-online.de',NULL,'0718141612',NULL,NULL,'0718146145',NULL,'m',6,'Mo I,Sa C',NULL,'$2b$10$v.kI/NKI7jnhBGizQlagcOOyp679fBI2vcZ6d/ZF6htn9KAIgY176',0,'guide, key'),(19,'Mitterhuber','Markus','MM','Robert-Koch-Str. 132','70565','Stuttgart','st155087@stud.uni-stuttgart.de','markusmitterhuber@outlook.de',NULL,'016092976568',NULL,NULL,NULL,'m',14,'Sa B',NULL,NULL,0,'deleted'),(20,'Schneider','Eva','ES','Baumblüte 20','73642','Welzheim','schneider-welzheim@t-online.de',NULL,'07182935424','01727168353',NULL,NULL,NULL,'w',NULL,'Sa B',NULL,NULL,0,'guide, key'),(21,'Güssmann','Marc','GM','Spreeweg 8','71522','Backnang','marcguessmann@aol.com',NULL,' 071911873059',NULL,'0711951341200',NULL,NULL,'m',18,'Mo I,Sa A',NULL,'$2b$10$JJ3t80P.Km8FVtZKmiXpYuXEnzGSHLrvln3CGsCGidzJ2BKFMElLy',0,'guide, key'),(22,'Recknagel','Malin','MR','Stöcklestr. 36','72070','Tübingen ','malin.recknagel@freenet.de',NULL,NULL,'015776638250',NULL,NULL,NULL,'w',9,'','hat sich aus der Gruppe abgemeldet',NULL,0,'deleted'),(23,'Schuler','Bernd','SC','','','','bkschuler@gmail.com',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mo I,Sa C',NULL,NULL,0,'guide'),(24,'Riedl','Christoph','RC','Schillerstr. 30','73773','Aichwald','wp.riedl@my-steuerberatung.com',NULL,'',NULL,'0711 4116772','0711 4116773',NULL,'m',NULL,NULL,NULL,NULL,0,NULL),(25,'Nastos','Xeno','XN','','','','XNastos@t-online.de',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mi II,Sa B',NULL,'$2b$10$wkwvzkZqhLJ8He2jXW9Ttu88I61f3JbghsRRIM0Dm7guxKRIY7EBC',0,'guide'),(26,'Bernhard','Ralf','RB','Alpenrosenstr. 22','70563','Stuttgart','ralf_bernhard@web.de',NULL,'0711 4204151',NULL,NULL,NULL,NULL,'m',11,'Mo II,Sa A',NULL,'$2b$10$n/YFHBS1EJoZP8W1JwT9XOKH9bW6V.UvGNOBHDtKHa/Bq8krtTBla',0,'guide, key'); INSERT INTO `beos` VALUES (1,'Brückner','Steffen','Brü','Hegelstr. 10','71093','Weil im Schönbuch','brueckner@ccdastro.de',NULL,'070312627550',NULL,NULL,'070312627551',NULL,'m',2,'Mi II,Sa A',NULL,NULL,0),(2,'Dschida','Hans','HD','Rain 5','73660','Urbach','hansdschida1@gmail.com',NULL,'0718184322','015575359828','07195142599',NULL,NULL,'m',21,'Mi I,Sa C',NULL,'$2b$10$9EqDIW7xyTGsNE0NJqt5Pef1p/9C8FMj1tRfO0JPYkFaKouPey95W',0),(5,'Ess','Andrea','Ess','Beethovenweg 8','73630','Remshalden','andrea.ess@t-online.de',NULL,'071512703929',NULL,'07151566486',NULL,NULL,'w',NULL,NULL,NULL,NULL,0),(6,'Förnzler','Ulrich','','Ober Str. 12','7ß190','Stuttgart','papa.foernzler@gmx.de',NULL,'07112865188','01778239801',NULL,NULL,NULL,'m',12,NULL,NULL,NULL,0),(7,'Fürst','Reinhard','rxf','Forststr. 66a','70176','Stuttgart','rexfue@gmail.com',NULL,'07116369409','01713129481',NULL,NULL,'','m',4,'Mo I,Sa C',NULL,'$2b$10$nmpF4s1rgeVF.6o1Nv7nk.OvAlcEJKsrAWeiqgESyBcKtvxw0fZNS',0),(8,'Gertz','Martin','MG','Buhlstr. 39/1','71384','Weinstadt','martin.gertz@gmx.de','martin.gertz@stihl.de','071519459521','015788298545','07151262545','071512682545',NULL,'m',5,'Sa B','HOBS',NULL,0),(9,'Meyer-Hamme','Olaf','MH','Eichenweg 29','73650','Winterbach','olaf.meyer-hamme@gmx.de',NULL,'071814808656','015221962790','07181977050',NULL,NULL,'m',22,'Mi II,Sa C',NULL,NULL,0),(10,'Idler','Rudolf','ID','Stettener Str. 26/1','71394','Kernen','r.idler@freenet.de',NULL,NULL,'01782097963','07119576017',NULL,NULL,'m',11,'Sa A',NULL,NULL,0),(11,'Nikolaizig','Jörg','JN','Grundweinberge 22','71642','Ludwigsburg','joniko@web.de',NULL,'07141257447','015122894226','071316444249',NULL,NULL,'m',10,'Mo II,Sa A',NULL,NULL,0),(15,'Weishaar','Christoph','CW','Leintelstr. 48','71336','Waiblingen-Bittenfeld','erfinderwerkstatt@t-online.de','c.weishaar@pilz.de ','071462840172',NULL,'07113409216','07113409434',NULL,'m',7,'Mi I,Sa B',NULL,'$2b$10$wSc60txkGL8cvNEZeF8IS.Yzfm8xvM1yJ/EbbRJRI5GkK3ERpkesW',0),(16,'Zoller','Matthias','Zo','Rosenstr. 49','71063','Sindelfingen','mazoller@gmx.de',NULL,'07031876466','01713752637','071197242618',NULL,NULL,'m',8,'Mo II,Sa A',NULL,NULL,0),(17,'Keller','Hans-Ulrich','HUK','Planetarium','','Stuttgart','hans-ulrich.keller@stuttgart.de','planetarium@stuttgart.de','07114403350',NULL,'07111629226','07112163912',NULL,'m',1,NULL,NULL,NULL,0),(18,'Gräber','Hubert','HHG','Im Brunnengarten 20','73630','Remshalden','hubert.graeber@t-online.de',NULL,'0718141612',NULL,NULL,'0718146145',NULL,'m',6,'Mo I,Sa C',NULL,NULL,0),(19,'Mitterhuber','Markus','MM','Robert-Koch-Str. 132','70565','Stuttgart','st155087@stud.uni-stuttgart.de','markusmitterhuber@outlook.de',NULL,'016092976568',NULL,NULL,NULL,'m',14,'Sa B',NULL,NULL,0),(20,'Schneider','Eva','ES','Baumblüte 20','73642','Welzheim','schneider-welzheim@t-online.de',NULL,'07182935424','01727168353',NULL,NULL,NULL,'w',NULL,'Sa B',NULL,NULL,0),(21,'Güssmann','Marc','GM','Spreeweg 8','71522','Backnang','marcguessmann@aol.com',NULL,' 071911873059',NULL,'0711951341200',NULL,NULL,'m',18,'Mo I,Sa A',NULL,NULL,0),(22,'Recknagel','Malin','MR','Stöcklestr. 36','72070','Tübingen ','malin.recknagel@freenet.de',NULL,NULL,'015776638250',NULL,NULL,NULL,'w',9,'','hat sich aus der Gruppe abgemeldet',NULL,0),(23,'Schuler','Bernd','SC','','','','bkschuler@gmail.com',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mo I,Sa C',NULL,NULL,0),(24,'Riedl','Christoph','RC','Schillerstr. 30','73773','Aichwald','wp.riedl@my-steuerberatung.com',NULL,'',NULL,'0711 4116772','0711 4116773',NULL,'m',NULL,NULL,NULL,NULL,0),(25,'Nastos','Xeno','XN','','','','XNastos@t-online.de',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mi II,Sa B',NULL,NULL,0),(26,'Bernhard','Ralf','RB','Alpenrosenstr. 22','70563','Stuttgart','ralf_bernhard@web.de',NULL,'0711 4204151',NULL,NULL,NULL,NULL,'m',11,'Mo II,Sa A',NULL,NULL,0);
/*!40000 ALTER TABLE `beos` ENABLE KEYS */; /*!40000 ALTER TABLE `beos` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -72,7 +71,7 @@ CREATE TABLE `objekte` (
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`), PRIMARY KEY (`ID`),
UNIQUE KEY `Name` (`Name`) UNIQUE KEY `Name` (`Name`)
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -81,7 +80,7 @@ CREATE TABLE `objekte` (
LOCK TABLES `objekte` WRITE; LOCK TABLES `objekte` WRITE;
/*!40000 ALTER TABLE `objekte` DISABLE KEYS */; /*!40000 ALTER TABLE `objekte` DISABLE KEYS */;
INSERT INTO `objekte` VALUES (1,'Mond','2026-05-03 19:14:28','2026-04-27 15:44:00'),(2,'Jupiter','2026-05-03 19:27:25','2026-04-27 15:44:00'),(3,'M57','2026-05-03 16:40:23','2026-04-27 15:44:00'),(4,'Abend Stern','2026-04-28 11:35:55','2026-04-27 15:56:24'),(5,'eps Lyr','2026-04-27 18:34:44','2026-04-27 18:17:16'),(6,'beta Cyg','2026-04-28 11:39:17','2026-04-27 18:34:44'),(7,'M31','2026-05-03 16:47:00','2026-04-28 11:39:17'),(8,'M45','2026-05-03 19:27:25','2026-04-29 07:59:50'),(9,'Saturn','2026-05-02 08:07:36','2026-04-29 08:06:16'),(10,'M42','2026-05-03 19:27:25','2026-04-29 08:06:16'),(11,'alp Ori','2026-04-29 08:06:16','2026-04-29 08:06:16'),(12,'alpha Ori','2026-04-29 08:07:16','2026-04-29 08:07:16'),(13,'M44','2026-05-02 07:21:11','2026-05-01 07:29:14'),(14,'M82','2026-05-03 19:27:25','2026-05-01 07:39:27'),(15,'gam Leo','2026-05-03 19:19:41','2026-05-01 07:46:06'),(16,'chi+kap Per','2026-05-02 08:07:36','2026-05-01 07:46:06'),(17,'Sternbilder','2026-05-03 19:27:25','2026-05-01 07:46:06'),(18,'Uranus','2026-05-02 08:01:04','2026-05-02 07:57:26'),(19,'M81','2026-05-03 19:27:25','2026-05-02 07:57:26'),(20,'M37','2026-05-02 08:01:04','2026-05-02 08:01:04'),(21,'ISS','2026-05-02 08:01:04','2026-05-02 08:01:04'),(22,'NGC2392','2026-05-03 19:19:41','2026-05-03 16:34:25'),(23,'M1','2026-05-03 16:40:23','2026-05-03 16:34:25'),(24,'Mars','2026-05-03 16:40:23','2026-05-03 16:34:25'),(25,'Neptun','2026-05-03 19:14:28','2026-05-03 19:14:28'),(26,'Venus','2026-05-03 19:14:28','2026-05-03 19:14:28'),(27,'M97','2026-05-03 19:19:41','2026-05-03 19:16:32'),(28,'M3','2026-05-03 19:19:41','2026-05-03 19:16:32'),(29,'M13','2026-05-03 19:27:25','2026-05-03 19:25:42'),(30,'M35','2026-05-03 19:27:25','2026-05-03 19:25:42'); INSERT INTO `objekte` VALUES (1,'Mond','2026-04-29 08:07:16','2026-04-27 15:44:00'),(2,'Jupiter','2026-04-29 08:07:16','2026-04-27 15:44:00'),(3,'M57','2026-04-28 11:39:17','2026-04-27 15:44:00'),(4,'Abend Stern','2026-04-28 11:35:55','2026-04-27 15:56:24'),(5,'eps Lyr','2026-04-27 18:34:44','2026-04-27 18:17:16'),(6,'beta Cyg','2026-04-28 11:39:17','2026-04-27 18:34:44'),(7,'M31','2026-04-28 11:39:17','2026-04-28 11:39:17'),(8,'M45','2026-04-29 08:07:16','2026-04-29 07:59:50'),(9,'Saturn','2026-04-29 08:07:16','2026-04-29 08:06:16'),(10,'M42','2026-04-29 08:07:16','2026-04-29 08:06:16'),(11,'alp Ori','2026-04-29 08:06:16','2026-04-29 08:06:16'),(12,'alpha Ori','2026-04-29 08:07:16','2026-04-29 08:07:16');
/*!40000 ALTER TABLE `objekte` ENABLE KEYS */; /*!40000 ALTER TABLE `objekte` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -105,11 +104,10 @@ CREATE TABLE `logbuch` (
`WetterDruck` decimal(7,1) DEFAULT NULL, `WetterDruck` decimal(7,1) DEFAULT NULL,
`created_by` int DEFAULT NULL, `created_by` int DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`SonderName` varchar(200) DEFAULT NULL,
PRIMARY KEY (`ID`), PRIMARY KEY (`ID`),
KEY `created_by` (`created_by`), KEY `created_by` (`created_by`),
CONSTRAINT `logbuch_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `beos` (`id`) CONSTRAINT `logbuch_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `beos` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=49 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -118,7 +116,7 @@ CREATE TABLE `logbuch` (
LOCK TABLES `logbuch` WRITE; LOCK TABLES `logbuch` WRITE;
/*!40000 ALTER TABLE `logbuch` DISABLE KEYS */; /*!40000 ALTER TABLE `logbuch` DISABLE KEYS */;
INSERT INTO `logbuch` VALUES (16,'West','RF','2026-03-02 19:45:00','2026-03-02 21:45:00',25,NULL,7.0,85.0,1024.0,7,'2026-05-01 07:24:33',NULL),(17,'West','BEOS','2026-03-03 20:00:00','2026-03-03 21:30:00',0,NULL,0.0,0.0,0.0,18,'2026-05-01 07:26:45',NULL),(18,'West','RF','2026-03-04 19:45:00','2026-03-04 21:40:00',15,NULL,7.0,71.0,71.0,15,'2026-05-01 07:29:14',NULL),(19,'West','TD','2026-03-07 14:00:00','2026-03-07 17:00:00',0,'Techniktag - WK\n- Rolladenkasten gereinigt\n- USB- und LAN-Kabel für die Montierung eingezogen\n- Umalufzwischenraum gereinigt\n',16.0,43.0,1022.0,8,'2026-05-01 07:33:38',NULL),(20,'West','RF','2026-03-07 20:00:00','2026-03-07 22:00:00',21,NULL,8.0,66.0,1023.0,26,'2026-05-01 07:39:27',NULL),(21,'West','SF','2026-03-08 19:00:00','2026-03-08 21:00:00',4,NULL,10.0,68.0,1023.0,2,'2026-05-01 07:46:06',NULL),(23,'West','Beob','2026-03-12 22:30:00','2026-03-12 23:20:00',0,'Aufnahme Jupiter',6.0,77.0,1020.0,25,'2026-05-01 08:57:31',NULL),(26,'West','SF','2026-03-17 20:00:00','2026-03-17 21:30:00',25,'Vortrag und Einrichtung gezeigt',5.0,90.0,1023.0,8,'2026-05-02 07:48:27','Landfrauen'),(27,'West','RF','2026-03-18 19:30:00','2026-03-18 22:10:00',24,NULL,7.0,77.0,1023.0,15,'2026-05-02 07:57:26',NULL),(28,'West','SF','2026-03-19 19:30:00','2026-03-19 22:00:00',9,NULL,6.0,61.0,1021.0,2,'2026-05-02 08:07:36','Rene Leucht'),(32,'West','SF','2026-03-22 19:30:00','2026-03-22 22:20:00',4,NULL,6.0,64.0,1014.0,2,'2026-05-03 16:34:25','Kroboth'),(33,'West','TD','2026-03-23 19:30:00','2026-03-23 21:50:00',0,'Gespräch mit Installateuren der Fa. Günther für Splitgeräte, Spendenkasse und Schlüssel mitgenommen',9.0,69.0,1018.0,18,'2026-05-03 16:37:01',NULL),(34,'West','TD','2026-03-24 13:00:00','2026-03-24 14:00:00',0,'Lichtprojekt mit Fa. Helmer und Wölftl (Stadt. Welzheim)',16.0,49.0,1020.0,18,'2026-05-03 16:38:54',NULL),(35,'West','TD','2026-03-25 18:00:00','2026-03-25 19:00:00',0,'Kühlschrank und Drucker gekennzeichnet. Div. Aufräumarbeiten',2.0,92.0,1006.0,18,'2026-05-03 16:42:51',NULL),(36,'West','TD','2026-03-27 10:00:00','2026-03-27 12:00:00',0,'Splitgeräte ans WLAN. Account eingerichtet. Allsky lässt sich nicht betreiben, hängt.NAS in der OK funktioniert nicht. Unterstützung durch die Herren Wölfl und Scheibner',1.0,78.0,1024.0,18,'2026-05-03 16:45:05',NULL),(37,'West','RF','2026-03-23 19:30:00','2026-03-23 21:50:00',4,NULL,8.0,51.0,1022.0,2,'2026-05-03 16:47:00',NULL),(38,'West','Sonst','2026-03-28 15:30:00','2026-03-28 15:40:00',0,'Reinigungsmittel zur Sternwarte gebracht',0.0,0.0,0.0,8,'2026-05-03 16:48:32',NULL),(39,'West','ToT','2026-03-28 15:45:00','2026-03-28 17:00:00',0,NULL,1.0,92.0,1020.0,11,'2026-05-03 16:50:38',NULL),(40,'West','TD','2026-03-29 14:30:00','2026-03-29 16:00:00',1,'Inspektion',7.0,60.0,1026.0,17,'2026-05-03 18:23:39',NULL),(41,'West','TD','2026-03-29 16:30:00','2026-03-29 18:00:00',0,'NAS wieder gestarte, allg. Inspektion. Besprechung HUK Büro. Wetterstaion abgebaut. AllSky wieder funktionfähig gemcht, Spendekasse an e.Schneider übergebn',0.0,0.0,0.0,7,'2026-05-03 18:30:14',NULL),(43,'West','TD','2026-04-01 09:30:00','2026-04-01 12:30:00',0,'Weitere BEO: Sh (Silvia Schidhuber) WK-Steuerraum und Seminarraum gereinigt und ausgemistet',22.1,0.0,1010.0,2,'2026-05-03 18:35:47',NULL),(44,'West','RF','2026-03-01 21:00:00','2026-03-01 22:40:00',0,'Keine Besucher eingetragen !',22.2,0.0,1010.0,2,'2026-05-03 19:14:28',NULL),(45,'West','RF','2026-03-04 22:00:00','2026-03-04 23:35:00',2,NULL,22.2,0.0,1010.0,2,'2026-05-03 19:16:32',NULL),(46,'West','RF','2026-04-04 22:00:00','2026-04-04 23:35:00',2,NULL,10.1,0.0,994.0,2,'2026-05-03 19:19:41',NULL),(47,'West','SF','2026-04-07 21:00:00','2026-04-07 23:00:00',0,'keine Besucher eingetragen !',8.2,0.0,996.0,21,'2026-05-03 19:25:42','eigene Schüler'),(48,'West','RF','2026-04-08 21:00:00','2026-04-08 22:30:00',0,'Keine Besucher eingetragen',15.6,0.0,1014.0,15,'2026-05-03 19:27:04',NULL); INSERT INTO `logbuch` VALUES (1,'West','RF','2026-04-27 20:00:00','2026-04-27 22:00:00',22,'Test\n',15.7,68.6,1006.3,7,'2026-04-27 15:44:00'),(2,'West','RF','2026-04-27 17:44:00','2026-04-27 17:44:00',0,NULL,11.6,89.0,1016.6,7,'2026-04-27 15:45:19'),(3,'West','RF','2026-04-27 17:52:00','2026-04-27 17:52:00',22,NULL,22.8,55.1,993.8,7,'2026-04-27 15:56:24'),(4,'West','RF','2026-04-27 18:06:00','2026-04-27 18:06:00',6,NULL,11.0,67.2,1002.7,7,'2026-04-27 16:06:59'),(5,'West','RF','2026-04-27 20:16:00','2026-04-27 20:16:00',1,NULL,20.3,67.9,1014.8,2,'2026-04-27 18:17:16'),(6,'West','RF','2026-04-28 09:45:00','2026-04-28 09:00:00',0,NULL,12.2,83.1,1017.1,2,'2026-04-28 07:00:01'),(8,'West','RF','2026-04-28 09:15:00','2026-04-28 09:15:00',0,NULL,9.6,56.8,1000.3,2,'2026-04-28 07:02:13'),(9,'West','RF','2026-04-28 09:15:00','2026-04-28 09:15:00',0,NULL,9.6,56.8,1000.3,2,'2026-04-28 07:02:41'),(14,'Pluto','Beob','2024-01-10 18:30:00','2024-01-10 23:00:00',0,NULL,16.4,43.4,1015.3,15,'2026-04-29 07:59:50'),(15,'West','Sonst','2024-01-20 18:00:00','2024-01-20 19:45:00',3,'Private Führung',8.5,53.0,1001.2,2,'2026-04-29 08:06:16');
/*!40000 ALTER TABLE `logbuch` ENABLE KEYS */; /*!40000 ALTER TABLE `logbuch` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -138,7 +136,7 @@ CREATE TABLE `logbuch_beos` (
KEY `BeoID` (`BeoID`), KEY `BeoID` (`BeoID`),
CONSTRAINT `logbuch_beos_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE, CONSTRAINT `logbuch_beos_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE,
CONSTRAINT `logbuch_beos_ibfk_2` FOREIGN KEY (`BeoID`) REFERENCES `beos` (`id`) CONSTRAINT `logbuch_beos_ibfk_2` FOREIGN KEY (`BeoID`) REFERENCES `beos` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=139 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -147,7 +145,7 @@ CREATE TABLE `logbuch_beos` (
LOCK TABLES `logbuch_beos` WRITE; LOCK TABLES `logbuch_beos` WRITE;
/*!40000 ALTER TABLE `logbuch_beos` DISABLE KEYS */; /*!40000 ALTER TABLE `logbuch_beos` DISABLE KEYS */;
INSERT INTO `logbuch_beos` VALUES (76,16,23),(77,16,7),(78,17,23),(79,17,24),(80,17,17),(81,17,18),(82,17,8),(83,17,9),(84,17,26),(85,17,25),(87,18,15),(88,18,25),(89,19,5),(90,19,8),(91,19,26),(92,19,25),(93,20,26),(94,20,25),(95,21,2),(96,23,25),(105,27,15),(106,27,9),(107,26,5),(108,26,8),(109,28,2),(115,34,18),(116,33,18),(117,32,2),(118,35,18),(119,36,18),(120,37,2),(122,39,11),(123,40,17),(124,41,7),(125,41,2),(126,41,18),(127,38,8),(129,43,2),(130,43,18),(131,44,2),(132,44,25),(133,45,2),(134,46,2),(136,48,15),(137,48,25),(138,47,21); INSERT INTO `logbuch_beos` VALUES (1,1,7),(2,1,2),(3,1,23),(4,2,7),(5,2,21),(6,3,7),(7,3,9),(8,4,7),(9,4,15),(11,5,2),(12,6,2),(14,8,2),(15,9,2),(38,14,15),(40,15,2);
/*!40000 ALTER TABLE `logbuch_beos` ENABLE KEYS */; /*!40000 ALTER TABLE `logbuch_beos` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -168,7 +166,7 @@ CREATE TABLE `logbuch_objekte` (
KEY `ObjektID` (`ObjektID`), KEY `ObjektID` (`ObjektID`),
CONSTRAINT `logbuch_objekte_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE, CONSTRAINT `logbuch_objekte_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE,
CONSTRAINT `logbuch_objekte_ibfk_2` FOREIGN KEY (`ObjektID`) REFERENCES `objekte` (`ID`) CONSTRAINT `logbuch_objekte_ibfk_2` FOREIGN KEY (`ObjektID`) REFERENCES `objekte` (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=175 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
-- --
@@ -177,7 +175,7 @@ CREATE TABLE `logbuch_objekte` (
LOCK TABLES `logbuch_objekte` WRITE; LOCK TABLES `logbuch_objekte` WRITE;
/*!40000 ALTER TABLE `logbuch_objekte` DISABLE KEYS */; /*!40000 ALTER TABLE `logbuch_objekte` DISABLE KEYS */;
INSERT INTO `logbuch_objekte` VALUES (55,16,2,'Jupiter'),(56,16,10,'M42'),(57,16,8,'M45'),(58,16,1,'Mond'),(59,18,2,'Jupiter'),(60,18,10,'M42'),(61,18,13,'M44'),(62,20,2,'Jupiter'),(63,20,10,'M42'),(64,20,8,'M45'),(65,20,14,'M82'),(66,21,16,'chi+kap Per'),(67,21,15,'gam Leo'),(68,21,2,'Jupiter'),(69,21,7,'M31'),(70,21,10,'M42'),(71,21,8,'M45'),(72,21,14,'M82'),(73,21,17,'Sternbilder'),(74,23,2,'Jupiter'),(85,27,2,'Jupiter'),(86,27,7,'M31'),(87,27,10,'M42'),(88,27,8,'M45'),(89,27,17,'Sternbilder'),(90,27,18,'Uranus'),(91,27,20,'M37'),(92,27,21,'ISS'),(93,28,15,'gam Leo'),(94,28,8,'M45'),(95,28,10,'M42'),(96,28,7,'M31'),(97,28,19,'M81'),(98,28,14,'M82'),(99,28,9,'Saturn'),(100,28,16,'chi+kap Per'),(101,28,17,'Sternbilder'),(112,32,15,'gam Leo'),(113,32,2,'Jupiter'),(114,32,23,'M1'),(115,32,10,'M42'),(116,32,8,'M45'),(117,32,3,'M57'),(118,32,24,'Mars'),(119,32,1,'Mond'),(120,32,22,'NGC2392'),(121,32,17,'Sternbilder'),(122,37,2,'Jupiter'),(123,37,1,'Mond'),(124,37,15,'gam Leo'),(125,37,8,'M45'),(126,37,10,'M42'),(127,37,7,'M31'),(128,37,19,'M81'),(129,37,14,'M82'),(130,37,17,'Sternbilder'),(131,44,8,'M45'),(132,44,10,'M42'),(133,44,25,'Neptun'),(134,44,2,'Jupiter'),(135,44,1,'Mond'),(136,44,26,'Venus'),(137,44,15,'gam Leo'),(138,45,2,'Jupiter'),(139,45,10,'M42'),(140,45,8,'M45'),(141,45,15,'gam Leo'),(142,45,19,'M81'),(143,45,14,'M82'),(144,45,22,'NGC2392'),(145,45,27,'M97'),(146,45,28,'M3'),(147,46,2,'Jupiter'),(148,46,10,'M42'),(149,46,8,'M45'),(150,46,28,'M3'),(151,46,15,'gam Leo'),(152,46,19,'M81'),(153,46,14,'M82'),(154,46,22,'NGC2392'),(155,46,27,'M97'),(164,48,10,'M42'),(165,48,2,'Jupiter'),(166,48,17,'Sternbilder'),(167,47,2,'Jupiter'),(168,47,29,'M13'),(169,47,30,'M35'),(170,47,10,'M42'),(171,47,8,'M45'),(172,47,19,'M81'),(173,47,14,'M82'),(174,47,17,'Sternbilder'); INSERT INTO `logbuch_objekte` VALUES (1,1,1,'Mond'),(2,1,2,'Jupiter'),(3,1,3,'M57'),(4,2,2,'Jupiter'),(5,3,4,'Abend Stern'),(6,4,4,'Abend Stern'),(7,4,3,'M57'),(10,5,4,'Abend Stern'),(11,5,5,'eps Lyr'),(12,5,1,'Mond'),(13,5,6,'beta Cyg'),(19,14,8,'M45'),(26,15,2,'Jupiter'),(27,15,10,'M42'),(28,15,8,'M45'),(29,15,1,'Mond'),(30,15,9,'Saturn'),(31,15,12,'alpha Ori');
/*!40000 ALTER TABLE `logbuch_objekte` ENABLE KEYS */; /*!40000 ALTER TABLE `logbuch_objekte` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
@@ -190,4 +188,4 @@ UNLOCK TABLES;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-05-08 17:43:48 -- Dump completed on 2026-04-30 5:31:48
Executable
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# Erstellt einen neuen MySQL-Container "logbuch_mysql" und migriert
# die 5 App-relevanten Tabellen aus dem bestehenden "db"-Container.
set -e
# ── Konfiguration ────────────────────────────────────────────────────────────
SRC_CONTAINER="db"
SRC_DB="sternwarte"
SRC_ROOT_PASS="SFluorit"
NEW_CONTAINER="logbuch_mysql"
NEW_DB="sternwarte"
NEW_ROOT_PASS="SFluorit" # ggf. hier ändern
NEW_PORT="3307" # Host-Port (3306 ist schon belegt)
MYSQL_IMAGE="arm64v8/mysql:lts"
NETWORK="sternwarte_default"
TABLES="beos objekte logbuch logbuch_beos logbuch_objekte"
# ── Hilfsfunktion ────────────────────────────────────────────────────────────
wait_for_mysql() {
local container="$1"
local pass="$2"
echo -n "Warte auf MySQL in '$container' "
for i in $(seq 1 60); do
if docker exec "$container" mysqladmin ping -u root -p"$pass" --silent 2>/dev/null; then
echo " bereit."
return 0
fi
echo -n "."
sleep 2
done
echo ""
echo "FEHLER: MySQL in '$container' nicht erreichbar nach 120s." >&2
exit 1
}
# ── Prüfen ob Quell-Container läuft ─────────────────────────────────────────
if ! docker inspect "$SRC_CONTAINER" &>/dev/null; then
echo "FEHLER: Quell-Container '$SRC_CONTAINER' nicht gefunden." >&2
exit 1
fi
echo "Quell-Container '$SRC_CONTAINER' gefunden."
# ── Alten Ziel-Container entfernen falls vorhanden ──────────────────────────
if docker inspect "$NEW_CONTAINER" &>/dev/null; then
echo "Container '$NEW_CONTAINER' existiert bereits — wird gestoppt und entfernt..."
docker rm -f "$NEW_CONTAINER"
fi
# ── Neuen Container starten ──────────────────────────────────────────────────
echo "Starte neuen Container '$NEW_CONTAINER'..."
docker run -d \
--name "$NEW_CONTAINER" \
--network "$NETWORK" \
-p "${NEW_PORT}:3306" \
-e MYSQL_ROOT_PASSWORD="$NEW_ROOT_PASS" \
-e MYSQL_DATABASE="$NEW_DB" \
"$MYSQL_IMAGE"
wait_for_mysql "$NEW_CONTAINER" "$NEW_ROOT_PASS"
# ── Daten migrieren ──────────────────────────────────────────────────────────
echo "Migriere Tabellen: $TABLES"
echo "(Dump von '$SRC_CONTAINER' → Import in '$NEW_CONTAINER')"
docker exec "$SRC_CONTAINER" mysqldump \
-u root -p"$SRC_ROOT_PASS" \
--single-transaction \
--no-tablespaces \
"$SRC_DB" $TABLES \
| docker exec -i "$NEW_CONTAINER" mysql \
-u root -p"$NEW_ROOT_PASS" \
"$NEW_DB"
echo "Migration abgeschlossen."
# ── Zeilenzähler zur Verifikation ────────────────────────────────────────────
echo ""
echo "Zeilenzähler (Quelle → Ziel):"
for TABLE in $TABLES; do
SRC_COUNT=$(docker exec "$SRC_CONTAINER" mysql -u root -p"$SRC_ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "$SRC_DB" 2>/dev/null)
DST_COUNT=$(docker exec "$NEW_CONTAINER" mysql -u root -p"$NEW_ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "$NEW_DB" 2>/dev/null)
STATUS="✓"
[ "$SRC_COUNT" != "$DST_COUNT" ] && STATUS="✗ ABWEICHUNG"
printf " %-25s %5s → %5s %s\n" "$TABLE" "$SRC_COUNT" "$DST_COUNT" "$STATUS"
done
# ── Abschluss ─────────────────────────────────────────────────────────────────
echo ""
echo "══════════════════════════════════════════════════════"
echo "Neuer Container: $NEW_CONTAINER"
echo " Netzwerk: $NETWORK"
echo " Host-Port: $NEW_PORT"
echo " Datenbank: $NEW_DB"
echo ""
echo "Nächster Schritt — .env anpassen:"
echo " DB_HOST=$NEW_CONTAINER"
echo " DB_PASS=$NEW_ROOT_PASS"
echo "══════════════════════════════════════════════════════"
-147
View File
@@ -1,147 +0,0 @@
#!/bin/bash
# Migriert die Datenbank von latin1 auf utf8mb4.
# Läuft auf dem Server im Verzeichnis der compose.yml.
# Voraussetzung: iconv installiert (apt install libc-bin)
set -e
CONTAINER="logbuch_mysql"
DB="sternwarte"
DUMP_LATIN1="/tmp/sternwarte_latin1.sql"
DUMP_UTF8="/tmp/sternwarte_utf8mb4.sql"
# Root-Passwort aus .env lesen
ROOT_PASS=$(grep DB_ROOT_PASS .env | cut -d= -f2)
if [ -z "$ROOT_PASS" ]; then
echo "FEHLER: DB_ROOT_PASS nicht in .env gefunden." >&2
exit 1
fi
echo "══════════════════════════════════════════════════════"
echo " latin1 → utf8mb4 Migration: $DB"
echo "══════════════════════════════════════════════════════"
echo ""
# ── Sicherheits-Backup ───────────────────────────────────────────────────────
BACKUP="/tmp/sternwarte_backup_$(date +%Y%m%d_%H%M%S).sql"
echo ">>> Erstelle Backup: $BACKUP"
docker exec "$CONTAINER" mysqldump \
-u root -p"$ROOT_PASS" \
--default-character-set=latin1 \
--single-transaction \
--no-tablespaces \
--set-gtid-purged=OFF \
"$DB" > "$BACKUP"
echo " Backup gespeichert: $BACKUP"
echo ""
# ── Dump als latin1 exportieren ──────────────────────────────────────────────
echo ">>> Exportiere Daten mit latin1-Zeichensatz..."
docker exec "$CONTAINER" mysqldump \
-u root -p"$ROOT_PASS" \
--default-character-set=latin1 \
--single-transaction \
--no-tablespaces \
--skip-set-charset \
--set-gtid-purged=OFF \
"$DB" > "$DUMP_LATIN1"
echo " Dump: $DUMP_LATIN1"
echo ""
# ── Bytes latin1 → utf8 konvertieren ────────────────────────────────────────
echo ">>> Konvertiere Bytes: latin1 → utf8..."
iconv -f latin1 -t utf8 "$DUMP_LATIN1" > "$DUMP_UTF8"
# Charset-Deklarationen im SQL ersetzen
sed -i \
-e 's/DEFAULT CHARSET=latin1/DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci/g' \
-e 's/COLLATE=latin1_swedish_ci//g' \
-e 's/CHARACTER SET latin1/CHARACTER SET utf8mb4/g' \
-e 's/COLLATE latin1_swedish_ci/COLLATE utf8mb4_unicode_ci/g' \
"$DUMP_UTF8"
echo " Konvertiert: $DUMP_UTF8"
echo ""
# ── Zeilenzähler vor Migration ───────────────────────────────────────────────
echo ">>> Zeilenzähler vor Migration:"
for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do
COUNT=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "$DB" 2>/dev/null || echo "n/a")
printf " %-25s %5s Zeilen\n" "$TABLE" "$COUNT"
done
echo ""
# ── Zieldatenbank anlegen ────────────────────────────────────────────────────
echo ">>> Lege Zieldatenbank an (utf8mb4)..."
docker exec -i "$CONTAINER" mysql -u root -p"$ROOT_PASS" <<EOF
DROP DATABASE IF EXISTS ${DB}_new;
CREATE DATABASE ${DB}_new CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
EOF
# ── utf8mb4-Dump importieren ─────────────────────────────────────────────────
echo ">>> Importiere utf8mb4-Daten..."
docker exec -i "$CONTAINER" mysql \
-u root -p"$ROOT_PASS" \
--default-character-set=utf8mb4 \
"${DB}_new" < "$DUMP_UTF8"
# ── Zeilenzähler nach Migration ──────────────────────────────────────────────
echo ""
echo ">>> Zeilenzähler nach Migration:"
ALL_OK=true
for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do
BEFORE=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "${DB}" 2>/dev/null || echo "?")
AFTER=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "${DB}_new" 2>/dev/null || echo "?")
STATUS="✓"
[ "$BEFORE" != "$AFTER" ] && STATUS="✗ ABWEICHUNG" && ALL_OK=false
printf " %-25s %5s → %5s %s\n" "$TABLE" "$BEFORE" "$AFTER" "$STATUS"
done
echo ""
if [ "$ALL_OK" = false ]; then
echo "FEHLER: Zeilenzähler stimmen nicht überein!" >&2
echo "Datenbank '${DB}_new' bleibt zur manuellen Prüfung erhalten." >&2
exit 1
fi
# ── Swap: sternwarte → sternwarte_old, sternwarte_new → sternwarte ───────────
echo ">>> Alle Zeilenzähler stimmen — tausche Datenbanken..."
TABLES=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SHOW TABLES;" "${DB}_new")
# Alte DB umbenennen (Tabellen nach sternwarte_old verschieben)
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"CREATE DATABASE IF NOT EXISTS ${DB}_old CHARACTER SET latin1;"
for TABLE in $TABLES; do
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"RENAME TABLE \`${DB}\`.\`$TABLE\` TO \`${DB}_old\`.\`$TABLE\`;"
done
# Neue DB nach sternwarte verschieben
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"ALTER DATABASE ${DB} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
for TABLE in $TABLES; do
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"RENAME TABLE \`${DB}_new\`.\`$TABLE\` TO \`${DB}\`.\`$TABLE\`;"
done
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"DROP DATABASE ${DB}_new; DROP DATABASE ${DB}_old;"
# ── Kollation bestätigen ─────────────────────────────────────────────────────
echo ""
echo ">>> Kollation der Tabellen:"
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '$DB' ORDER BY TABLE_NAME;" 2>/dev/null
echo ""
echo "══════════════════════════════════════════════════════"
echo " Migration erfolgreich abgeschlossen!"
echo " Backup: $BACKUP"
echo " App neu starten: docker compose up -d logbuch_app"
echo "══════════════════════════════════════════════════════"
+1 -1
View File
@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
{ {
source: '/(.*)', source: '/(.*)',
headers: [ headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
@@ -18,7 +19,6 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
}; };
export default nextConfig; export default nextConfig;
+2 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.5", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "logbuch", "name": "logbuch",
"version": "1.7.5", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
@@ -3298,7 +3298,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.10.2", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
"next": "16.1.6", "mysql2": "^3.22.3",
"next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
+1 -15
View File
@@ -3,8 +3,7 @@ import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'logbuch_session'; const SESSION_COOKIE_NAME = 'logbuch_session';
const secretKey = process.env.AUTH_SECRET; const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
const key = new TextEncoder().encode(secretKey); const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
@@ -14,11 +13,6 @@ export async function proxy(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Allow the statistik grafik proxy route to be called without an app session cookie
if (pathname.startsWith('/api/statistik/grafik')) {
return NextResponse.next();
}
const cookie = request.cookies.get(SESSION_COOKIE_NAME); const cookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!cookie?.value) { if (!cookie?.value) {
@@ -37,15 +31,7 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/', request.url)); return NextResponse.redirect(new URL('/', request.url));
} }
if (pathname === '/api/statistik/grafik') {
return NextResponse.next(); return NextResponse.next();
}
return NextResponse.next({
headers: {
'X-Frame-Options': 'DENY',
},
});
} catch { } catch {
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL('/login', request.url));
} }
-349
View File
@@ -1,349 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.65;
color: #1a1a2e;
background: #f4f6fb;
}
.page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff;
padding: 2.5rem 2rem 2rem;
border-radius: 12px;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
gap: 1.2rem;
}
header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
nav.toc {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.4rem 1.8rem;
margin-bottom: 2.5rem;
}
nav.toc h2 {
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
}
nav.toc ol {
list-style: none; counter-reset: toc;
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
}
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before {
content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-weight: 700; min-width: 1.4rem;
}
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; }
section {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.8rem 2rem;
margin-bottom: 1.5rem;
}
h2.section-title {
font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7;
display: flex; align-items: center; gap: 0.6rem;
}
h2.section-title .num {
background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
width: 1.6rem; height: 1.6rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
h3 {
font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
}
h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; }
ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
th {
background: #eef2fa; color: #2e4e8a; font-weight: 700;
text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
}
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; }
code {
background: #eef2fa; color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
}
.callout {
background: #eef6fb; border-left: 4px solid #85b7d7;
border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
margin: 0.9rem 0; font-size: 0.9rem;
}
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; }
.back-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
text-decoration: none; cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; }
}
@media print {
body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; }
header { border-radius: 0; background: #1a2d5a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
section { border: 1px solid #ccc; break-inside: avoid; }
nav.toc { break-after: page; }
a { color: inherit; text-decoration: none; }
.back-btn { display: none; }
}
</style>
</head>
<body>
<div class="page">
<a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header>
<div class="star"></div>
<div>
<h1>Bedienungsanleitung</h1>
<p>Führungsbuch Sternwarte Welzheim</p>
</div>
</header>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
<ol>
<li><a href="#s1">Anmelden</a></li>
<li><a href="#s2">Grundaufbau der App</a></li>
<li><a href="#s3">Eintrag erfassen (Tab „Eingabe")</a></li>
<li><a href="#s4">Einträge einsehen und verwalten (Tab „Liste")</a></li>
<li><a href="#s5">Jahresstatistik (Tab „Statistik")</a></li>
<li><a href="#s6">Drucken</a></li>
<li><a href="#s7">Administration (nur Admins)</a></li>
</ol>
</nav>
<!-- ── 1. Anmelden ── -->
<section id="s1">
<h2 class="section-title"><span class="num">1</span> Anmelden</h2>
<p>Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.</p>
<ul>
<li><strong>Kürzel</strong> das persönliche BEO-Kürzel (z. B. <code>RXF</code>)</li>
<li><strong>Passwort</strong> individuell gesetztes Passwort</li>
</ul>
<div class="callout warn">
Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>.
</div>
</section>
<!-- ── 2. Grundaufbau ── -->
<section id="s2">
<h2 class="section-title"><span class="num">2</span> Grundaufbau der App</h2>
<h3>Kuppel-Auswahl</h3>
<p>Oben befinden sich vier Reiter für die vier Kuppeln:</p>
<table>
<thead><tr><th>Reiter</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>West</td><td>West-Kuppel</td></tr>
<tr><td>Ost</td><td>Ost-Kuppel</td></tr>
<tr><td>Süd</td><td>Süd-Kuppel</td></tr>
<tr><td>Pluto</td><td>Pluto-Kuppel</td></tr>
</tbody>
</table>
<p>Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.</p>
<h3>Funktions-Tabs</h3>
<p>Unterhalb der Kuppelauswahl gibt es drei Tabs:</p>
<table>
<thead><tr><th>Tab</th><th>Funktion</th></tr></thead>
<tbody>
<tr><td><strong>Eingabe</strong></td><td>Neuen Eintrag anlegen oder bestehenden bearbeiten</td></tr>
<tr><td><strong>Liste</strong></td><td>Alle Einträge monatsweise ansehen, bearbeiten oder löschen</td></tr>
<tr><td><strong>Statistik</strong></td><td>Jahresübersicht Besucher und Führungen</td></tr>
</tbody>
</table>
</section>
<!-- ── 3. Eingabe ── -->
<section id="s3">
<h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2>
<h3>Pflichtfelder</h3>
<p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p>
<table>
<thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Reguläre Führung</td><td>Reguläre öffentliche Führung</td></tr>
<tr><td>Sonderführung</td><td>Für Gruppen, Schulen etc.</td></tr>
<tr><td>Sonnenführung</td><td>Führung mit Sonnenbeobachtung</td></tr>
<tr><td>Privatführung</td><td>Private Führung</td></tr>
<tr><td>BEOS</td><td>BEO-Sitzung (keine Besucher/Objekte)</td></tr>
<tr><td>TD</td><td>Treff/Diskussion (keine Besucher/Objekte)</td></tr>
<tr><td>Beobachtung</td><td>Reine Beobachtung ohne Führung</td></tr>
<tr><td>ToT</td><td>Teleskop ohne Termin</td></tr>
<tr><td>Sonstiges</td><td>Sonstige Veranstaltung</td></tr>
</tbody>
</table>
<p><strong>Datum</strong> Datum der Veranstaltung (Standardwert: heute).</p>
<p><strong>Startzeit / Endzeit</strong> Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. <code>8</code> oder <code>23</code>) und das Feld verlassen, werden die Minuten automatisch auf <code>00</code> gesetzt.</p>
<p><strong>Besucher</strong> Anzahl der Besucher (nicht sichtbar bei BEOS und TD).</p>
<h3>Optionale Felder</h3>
<p><strong>Name / Gruppe</strong> erscheint nur bei Sonderführung; Name der Gruppe oder Person.</p>
<p><strong>BEOs</strong> beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.</p>
<p><strong>Beobachtete Objekte</strong> nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:</p>
<ul>
<li>Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.</li>
<li>Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.</li>
<li>Noch unbekannte Objekte einfach eintippen am Ende der Dropdown-Liste erscheint dann <strong>+ „[Name]" hinzufügen</strong>. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.</li>
<li>Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.</li>
</ul>
<p><strong>Bemerkungen</strong> freier Text, max. 500 Zeichen.</p>
<p><strong>Wetterdaten</strong> Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. <code>-5</code>) können direkt eingegeben werden.</p>
<h3>Eintrag speichern</h3>
<p>Schaltfläche <strong>Eintrag speichern</strong> unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.</p>
<div class="callout">
Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau.
</div>
<h3>Eintrag bearbeiten</h3>
<p>Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung <strong>Änderungen speichern</strong> klicken oder mit <strong>Abbrechen</strong> verwerfen.</p>
</section>
<!-- ── 4. Liste ── -->
<section id="s4">
<h2 class="section-title"><span class="num">4</span> Einträge einsehen und verwalten (Tab „Liste")</h2>
<h3>Werkzeugleiste</h3>
<p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p>
<ul>
<li><strong>Monatsnavigation</strong> Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; <strong>Aktueller Monat</strong> springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).</li>
<li><strong>Suchfeld</strong> Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.</li>
<li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li>
</ul>
<h3>Tabelleninhalt</h3>
<p>Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte <strong>fettgedruckt</strong> und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.</p>
<h3>Eintrag bearbeiten</h3>
<p>Stift-Symbol ✎ rechts in der Zeile.</p>
<h3>Eintrag löschen</h3>
<p>× rechts in der Zeile es erscheint ein Bestätigungsdialog.</p>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong>.</div>
<h3>Seitennavigation</h3>
<p>Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.</p>
</section>
<!-- ── 5. Statistik ── -->
<section id="s5">
<h2 class="section-title"><span class="num">5</span> Jahresstatistik (Tab „Statistik")</h2>
<p>Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.</p>
<ul>
<li><strong>Jahr</strong> oben links änderbar (Eingabefeld).</li>
<li>Darunter vier Kennzahlen-Kacheln:
<ul>
<li>Kumulierte Besucher des Jahres für die gewählte Kuppel</li>
<li>Führungstage des Jahres für die gewählte Kuppel</li>
<li>Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)</li>
<li>Führungstage für die gesamte Sternwarte</li>
</ul>
</li>
</ul>
<div class="callout">
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
</div>
</section>
<!-- ── 6. Drucken ── -->
<section id="s6">
<h2 class="section-title"><span class="num">6</span> Drucken</h2>
<p><strong>Im Tab „Liste"</strong> Schaltfläche <strong>🖨 Drucken</strong> oben rechts in der Werkzeugleiste:</p>
<ul>
<li>Es werden <strong>alle Einträge des aktuell gewählten Monats</strong> geladen (nicht nur die angezeigte Seite).</li>
<li>Die Reihenfolge ist beim Ausdruck <strong>chronologisch</strong> (ältester Eintrag zuerst).</li>
<li>Navigations- und Aktionselemente werden ausgeblendet; oben erscheint eine Kopfzeile mit Kuppelname und Druckdatum.</li>
<li>Seitenformat: A4 Hochformat, Rand 1,5 cm.</li>
</ul>
<p><strong>Im Tab „Statistik"</strong> ebenfalls eine <strong>🖨 Drucken</strong>-Schaltfläche für die Jahresstatistik.</p>
</section>
<!-- ── 7. Administration ── -->
<section id="s7">
<h2 class="section-title"><span class="num">7</span> Administration <small style="font-size:0.7em;font-weight:500;color:#6b7a9b;">(nur Admins)</small></h2>
<p>Erreichbar über die Schaltfläche <strong>Admin</strong> oben rechts (nur für Benutzer mit Admin-Rolle sichtbar). Die Admin-Seite hat zwei Tabs:</p>
<h3>Benutzerverwaltung</h3>
<p>Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.</p>
<p><strong>Passwort zurücksetzen</strong> Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort <code>welzheim</code> verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.</p>
<h3>Objektverwaltung</h3>
<p>Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.</p>
<ul>
<li><strong>Neues Objekt anlegen</strong> Feld unten ausfüllen und <strong>Hinzufügen</strong> klicken.</li>
<li><strong>Objekt umbenennen</strong> Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li>
<li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
</ul>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
</section>
</div>
</body>
</html>
+8 -14
View File
@@ -4,23 +4,19 @@ export type ArtFuehrung = 'RF' | 'SF' | 'PrF' | 'BEOS' | 'SonF' | 'TD' | 'Beob'
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto']; export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
export const ARTEN_MAP: Record<ArtFuehrung, string> = { export const ARTEN_MAP: Record<ArtFuehrung, string> = {
RF: 'regulär', RF: 'Reguläre Führung',
SF: 'sonder', SF: 'Sonderführung',
SonF: 'sonnen', PrF: 'Private Führung',
PrF: 'privat', BEOS: 'BEO-Sitzung',
BEOS: '', SonF: 'Sonnenführung',
TD: '', TD: 'Technischer Dienst',
Beob: 'Beobachtung', Beob: 'Beobachtung',
ToT: '', ToT: 'Tag der offenen Tür',
Sonst: 'Sonstiges', Sonst:'Sonstiges',
}; };
export const ARTEN = Object.keys(ARTEN_MAP) as ArtFuehrung[]; export const ARTEN = Object.keys(ARTEN_MAP) as ArtFuehrung[];
export function artLabel(a: ArtFuehrung): string {
return ARTEN_MAP[a] || a;
}
export interface BeoOption { export interface BeoOption {
ID: number; ID: number;
Kuerzel: string; Kuerzel: string;
@@ -47,7 +43,6 @@ export interface LogbuchEintrag {
ID: number; ID: number;
Kuppel: Kuppel; Kuppel: Kuppel;
ArtFuehrung: ArtFuehrung; ArtFuehrung: ArtFuehrung;
SonderName: string | null;
Beginn: string; Beginn: string;
Ende: string; Ende: string;
Besucher: number; Besucher: number;
@@ -56,7 +51,6 @@ export interface LogbuchEintrag {
WetterFeuchte: number | null; WetterFeuchte: number | null;
WetterDruck: number | null; WetterDruck: number | null;
created_by: number | null; created_by: number | null;
created_by_kuerzel: string | null;
created_at: string; created_at: string;
BEOs: string; BEOs: string;
Objekte: string; Objekte: string;