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