Compare commits

...

7 Commits

Author SHA1 Message Date
admin 5e438863ab chore: feat/wetter → main (Version 1.15.0) 2026-08-08 21:11:17 +02:00
admin 5e37a9427c feat: Wetter zum gewählten Datum und zur Endzeit übernehmen — Version 1.15.0
Bisher stand im Formular immer der aktuelle Messwert, auch bei nachträglich
erfassten Einträgen. Ändert der Benutzer nun Datum oder Endzeit, werden
Temperatur, Feuchte und Luftdruck auf die Messung zu diesem Zeitpunkt
umgestellt.

/api/wetter nimmt dafür optional ?zeit=YYYY-MM-DDTHH:MM, holt über /range das
Fenster ±20 Minuten und liefert die nächstgelegene Messung. Wichtig: die
Wetter-API deutet Parameter ohne Zeitzone als UTC — die Route rechnet deshalb
Europe/Berlin um, sonst läge das Ergebnis im Sommer zwei Stunden daneben.

Gibt es keine Messung (Zukunft, Ausfall der Station), bleiben die bisherigen
Werte stehen und es erscheint ein Hinweis unter den Feldern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:11:17 +02:00
admin 46e0254038 chore: feat/fuehrungen → main (Version 1.14.0) 2026-08-08 18:03:50 +02:00
admin b3fbc391b1 feat: Tab „Führungen" für ausgefallene und verschobene Sonderführungen — Version 1.14.0
Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen
Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus
SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für
jeden angemeldeten BEO.

Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert
der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide
Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt
sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar.

Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs,
Design in scripts/anleitung.template.html), automatisch vor jedem Build.
public/anleitung.html ist deshalb nicht mehr in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:03:43 +02:00
admin 1945f1f8e0 chore: feat/spende → main (Version 1.13.0) 2026-08-08 14:47:11 +02:00
admin b5b75e0d77 feat: Sonderführungen automatisch im Register SoFue2 nachtragen — Version 1.13.0
Beim Speichern eines Logbuch-Eintrags der Art 'SF' wird der passende
Datensatz in SoFue2 über das Datum gesucht und dort stattgefunden=1,
anzahl_echt, bezahlt und remarks gesetzt. Gibt es an dem Tag mehrere
Führungen, gewinnt die zeitlich nächstgelegene.

Das Ergebnis kommt als Feld 'sofue' aus der PHP-Bridge zurück und wird
über dem Formular angezeigt — grün bei Erfolg, gelb, wenn keine Führung
gefunden wurde. Der Logbuch-Eintrag wird in dem Fall trotzdem gespeichert.
Die Meldung liegt in MainClient, weil LogbuchForm beim Bearbeiten durch
onSaved() neu gemountet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:57:22 +02:00
admin cfa1852d5e feat: Spende-Feld bei Sonderführungen — Version 1.12.0
Neue Spalten Spende (bar/ueberw/kasse/keine) und SpendeBetrag in der
Tabelle logbuch. Im Formular erscheint die Auswahl nur bei Art 'SF',
rechts neben Name/Gruppe; bei 'bar' zusätzlich ein Pflichtfeld für den
Betrag. Anzeige in der Liste als Unterzeile unter der Art.

Migration migrate_spende.sql muss vor dem Deploy manuell eingespielt
werden; die zugehörige SQL-Änderung liegt in DB4js_all.php im
sternwarte-Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:38:53 +02:00
21 changed files with 995 additions and 531 deletions
+2
View File
@@ -5,3 +5,5 @@
node_modules
.next
*.md
# Quelle der Anleitung — wird beim Build zu public/anleitung.html verarbeitet
!ANLEITUNG.md
+3
View File
@@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts
backup.git/
DB_BACKUP/
# wird aus ANLEITUNG.md erzeugt, nicht von Hand gepflegt
public/anleitung.html
+55 -12
View File
@@ -6,9 +6,10 @@
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)
5. [Ausgefallene und verschobene Führungen (Tab „Führungen")](#5-ausgefallene-und-verschobene-führungen-tab-führungen)
6. [Jahresstatistik (Tab „Statistik")](#6-jahresstatistik-tab-statistik)
7. [Drucken](#7-drucken)
8. [Administration (nur Admins)](#8-administration-nur-admins)
---
@@ -19,7 +20,7 @@ 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`.
> [!WARNUNG] Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
---
@@ -40,13 +41,14 @@ Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewäh
### Funktions-Tabs
Unterhalb der Kuppelauswahl gibt es drei Tabs:
Unterhalb der Kuppelauswahl gibt es vier 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 |
| **Führungen** | Kommende Sonderführungen absagen oder verschieben (ganz rechts) |
---
@@ -78,6 +80,19 @@ Unterhalb der Kuppelauswahl gibt es drei Tabs:
**Name / Gruppe** erscheint nur bei Sonderführung; Name der Gruppe oder Person.
**Spende** erscheint nur bei Sonderführung, rechts neben Name / Gruppe. Auswahl zwischen `bar`, `überw.`, `kasse` und `keine`; die Angabe ist bei Sonderführungen verpflichtend. Bei `bar` erscheint zusätzlich ein Feld **Betrag (€)**, in das der erhaltene Betrag eingetragen werden muss (Komma oder Punkt als Dezimaltrennzeichen, z. B. `25,50`).
> **Fällt eine Sonderführung aus?** Dafür gibt es keinen Logbuch-Eintrag — das wird im Reiter
> **Führungen** erledigt (siehe unten).
> **Sonderführungen werden automatisch nachgetragen.** Beim Speichern eines Eintrags der Art
> Sonderführung wird die passende Führung im Sonderführungs-Register über das Datum gesucht und
> dort als *stattgefunden* markiert; Besucherzahl, Spende und Bemerkungen werden mit übernommen.
> Ein Nachtragen im Beo-Formular entfällt damit. Gibt es an dem Tag mehrere Führungen, wird die
> zeitlich nächstgelegene genommen. Wird gar keine gefunden, erscheint über dem Formular eine
> gelbe Meldung — der Logbuch-Eintrag ist trotzdem gespeichert, nur das Register muss dann von
> Hand gepflegt werden.
**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:
@@ -88,7 +103,11 @@ Unterhalb der Kuppelauswahl gibt es drei Tabs:
**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.
**Wetterdaten** Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch von der Wetterstation vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. `-5`) können direkt eingegeben werden.
Beim Öffnen des Formulars erscheint der aktuelle Messwert. Wird anschließend **Datum oder Endzeit geändert**, werden die drei Werte automatisch auf den Messwert zu diesem Zeitpunkt umgestellt — ein nachträglich erfasster Eintrag bekommt so das Wetter, das damals herrschte, und nicht das von heute. Maßgeblich ist die **Endzeit** des Eintrags; verwendet wird die Messung, die dieser Uhrzeit am nächsten liegt.
> [!HINWEIS] Gibt es zu dem Zeitpunkt keine Messung — etwa bei einem Termin in der Zukunft oder während eines Ausfalls der Station — bleiben die bisherigen Werte stehen und es erscheint ein kurzer Hinweis unter den Feldern. Die Werte können dann von Hand eingetragen werden.
### Eintrag speichern
@@ -110,7 +129,7 @@ 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).
- **🖨 Drucken** siehe Abschnitt [Drucken](#7-drucken).
### Tabelleninhalt
@@ -122,7 +141,9 @@ Stift-Symbol ✎ rechts in der Zeile.
### Eintrag löschen
× rechts in der Zeile es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich**.
× rechts in der Zeile es erscheint ein Bestätigungsdialog.
> [!ACHTUNG] Das Löschen ist **unwiderruflich**.
### Seitennavigation
@@ -130,7 +151,27 @@ Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand
---
## 5. Jahresstatistik (Tab „Statistik")
## 5. Ausgefallene und verschobene Führungen (Tab „Führungen")
Der Tab zeigt alle **kommenden zugesagten Sonderführungen** aus dem Sonderführungs-Register, aufsteigend nach Termin: Datum und Uhrzeit, Gruppe, Personenzahl und zugeteilter BEO.
Findet eine dieser Führungen nicht statt, wird das hier vermerkt — ein Logbuch-Eintrag ist dafür weder nötig noch sinnvoll, denn die Führung hat ja nicht stattgefunden.
### Ausgefallen
Schaltfläche **Ausgefallen** in der jeweiligen Zeile. Es öffnet sich ein Feld für den Grund (optional, z. B. „Gruppe hat abgesagt"), danach **Wirklich absagen**. Die Führung wird im Register auf *abgesagt* gesetzt; der ursprüngliche Termin bleibt dabei erhalten. Der Eintrag verschwindet anschließend aus der Liste.
### Verschoben
Schaltfläche **Verschoben**. Es erscheinen Datum und Uhrzeit des neuen Termins (vorbelegt mit dem alten) sowie das Grundfeld. Nach **Verschieben** trägt das Register den neuen Termin, der bisherige wird als Vortermin gesichert.
In beiden Fällen wird eine datierte Zeile mit dem eigenen Kürzel an die Bemerkung des Registereintrags angehängt, z. B. `08.08.2026 rxf: verschoben vom 12.08.2026 18:00 auf 19.09.2026 20:30 — Wetter`. Bei mehrfachem Verschieben entsteht so eine lückenlose Terminhistorie.
Findet die verschobene Führung später statt, genügt ein ganz normaler Logbuch-Eintrag am **neuen** Datum — die Führung wird dann wie gewohnt automatisch als stattgefunden nachgetragen.
---
## 6. Jahresstatistik (Tab „Statistik")
Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.
@@ -145,7 +186,7 @@ Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsse
---
## 6. Drucken
## 7. Drucken
Im Tab **Liste**: Schaltfläche **🖨 Drucken** oben rechts in der Liste.
@@ -158,7 +199,7 @@ Im Tab **Statistik**: ebenfalls eine **🖨 Drucken**-Schaltfläche für die Jah
---
## 7. Administration (nur Admins)
## 8. Administration (nur Admins)
Erreichbar über die Schaltfläche **Admin** oben rechts (nur für Benutzer mit Admin-Rolle sichtbar).
@@ -176,5 +217,7 @@ 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.
- **Objekt löschen**: × in der Zeile es erscheint ein Bestätigungsdialog.
> [!ACHTUNG] Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.
+15 -1
View File
@@ -33,7 +33,21 @@ Next.js 16 App Router application. All pages are server components; interactive
## Data model
`ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object.
`ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object. `SF` (Sonderführung) additionally shows `SonderName` plus the mandatory `Spende` select (`bar`, `ueberw`, `kasse`, `keine` — labels in `SPENDE_MAP`); `SpendeBetrag` is only filled for `bar`. Both columns stay `NULL` for every other Art, enforced client-side in `LogbuchForm` and server-side in `spendeValues()` in `DB4js_all.php`.
Saving an `SF` entry also writes back into the Sonderführungs register `SoFue2` (`stattgefunden`, `anzahl_echt`, `bezahlt`, `remarks`) via `RepoSoFue::ausLogbuch()` in `DB4js_all.php` — matched by `DATE(wtermin)` against the entry's Beginn, nearest time wins. It runs in the dispatcher **after** the logbuch transaction commits and never throws; the outcome travels back as a `sofue` field in the API response and is shown above the form by `MainClient`. Note `SoFue2` is latin1 while `logbuch` is utf8mb4, and `remarks` holds only 100 chars — `cp1252Safe()` handles both.
Führungen that fall out never produce a logbuch entry. The **Führungen** tab (`components/Fuehrungen.tsx`, open to every logged-in BEO) lists upcoming `status=2` rows and offers cancel (`status=3`, date kept) and postpone (old `wtermin` saved into the long-unused `verlegt` column, new date written). Both append a dated line with the user's Kürzel to `bemerkung` rather than replacing it. Backed by `LB_SOFUE_TERMINE` / `LB_SOFUE_ABSAGEN` / `LB_SOFUE_VERSCHIEBEN` in `DB4js_all.php` and the routes under `app/api/sofue/`.
## Weather
`app/api/wetter/route.ts` proxies `stwwetter.fuerst-stuttgart.de/api/weather`. Without params it returns `/latest`; with `?zeit=YYYY-MM-DDTHH:MM` (local time) it queries `/range` ±20 min around that moment and returns the closest reading, or 404 when the station has nothing (future dates, outages). **The weather API treats naive query params as UTC**, so the route converts Europe/Berlin → UTC via `Intl` offsets — without that, summer readings are off by two hours. `LogbuchForm` refetches whenever `ende` changes (date or Endzeit), skipping the first run so an edited entry keeps its stored values.
## Documentation
`ANLEITUNG.md` is the single source for the user manual — edit only this file. `public/anleitung.html` (linked from the app footer) is **generated** by `scripts/build-anleitung.mjs` and is untracked (`.gitignore`); never hand-edit it. `npm run anleitung` builds it; a `prebuild` hook runs the same script before every `next build`, so the Docker image always gets a current copy (`.dockerignore` excludes `*.md` but re-includes `ANLEITUNG.md` for exactly this).
The page design lives in `scripts/anleitung.template.html` (placeholders `{{H1}}`, `{{SUBTITLE}}`, `{{TOC}}`, `{{SECTIONS}}`). Markdown conventions the generator relies on: `## N. Titel` becomes `<section id="sN">` with a numbered heading, the `## Inhaltsverzeichnis` list feeds the TOC, in-page links like `(#7-drucken)` are rewritten to `#s7`, and blockquotes become callouts — `> [!WARNUNG]` yellow, `> [!ACHTUNG]` red, plain or `> [!HINWEIS]` blue. Renumbering a section means updating both its heading and the TOC entry.
## Deployment
+56 -5
View File
@@ -2,13 +2,32 @@
import { useState } from 'react';
import { KUPPELN } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag, SofueStatus } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList';
import Statistik from '@/components/Statistik';
import Fahrkosten from '@/components/Fahrkosten';
import Fuehrungen from '@/components/Fuehrungen';
import packageJson from '@/package.json';
function datumDe(iso?: string): string {
if (!iso) return '';
const [j, m, t] = iso.slice(0, 10).split('-');
return t && m && j ? `${t}.${m}.${j}` : iso;
}
/** Rückmeldung zur Rückschreibung einer Sonderführung ins Register SoFue2. */
function sofueMeldung(s: SofueStatus): string {
switch (s.status) {
case 'ok':
return `Sonderführung vom ${datumDe(s.wtermin ?? s.datum)} im Register als stattgefunden eingetragen.`;
case 'kein_termin':
return `Keine Sonderführung am ${datumDe(s.datum)} gefunden — das Sonderführungs-Register wurde nicht aktualisiert.`;
default:
return 'Das Sonderführungs-Register konnte nicht aktualisiert werden. Bitte den Eintrag dort von Hand nachtragen.';
}
}
interface Props {
kuerzel: string;
beoId: number;
@@ -18,10 +37,11 @@ interface Props {
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'fuehrungen' | '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 [sofueHinweis, setSofueHinweis] = useState<SofueStatus | null>(null);
const grafikSrc = '/api/statistik/grafik';
@@ -32,9 +52,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName };
function handleSaved() {
function handleSaved(sofue?: SofueStatus | null) {
setRefreshKey((k) => k + 1);
setEditEntry(null);
setSofueHinweis(sofue ?? null);
}
function handleEdit(entry: LogbuchEintrag) {
@@ -120,7 +141,7 @@ 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', 'fahrkosten'] as const)
{(['eingabe', 'liste', 'statistik', 'fahrkosten', 'fuehrungen'] as const)
.filter((tab) => tab !== 'fahrkosten' || role?.includes('admin') || role?.includes('master'))
.map((tab) => (
<button
@@ -135,7 +156,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'}
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'fuehrungen' ? 'Führungen' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'}
</button>
))}
</div>
@@ -148,6 +169,26 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
Eintrag bearbeiten (ID {editEntry.ID})
</div>
)}
{sofueHinweis && sofueHinweis.status !== 'uebersprungen' && (
<div
className={
'mb-3 text-sm rounded-lg px-3 py-2 border flex justify-between items-start gap-3 ' +
(sofueHinweis.status === 'ok'
? 'text-green-700 bg-green-50 border-green-300'
: 'text-amber-800 bg-amber-50 border-amber-400')
}
>
<span>{sofueMeldung(sofueHinweis)}</span>
<button
type="button"
onClick={() => setSofueHinweis(null)}
className="shrink-0 font-medium hover:opacity-70"
aria-label="Meldung schließen"
>
</button>
</div>
)}
<LogbuchForm
key={`${activeKuppel}-${editEntry?.ID ?? 'new'}`}
kuppel={activeKuppel}
@@ -224,6 +265,16 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
</div>
)}
{/* Führungen-Tab: kommende Sonderführungen absagen oder verschieben */}
{activeTab === 'fuehrungen' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
<div className="mb-2">
<span className="text-sm font-semibold text-gray-900">Kommende Sonderführungen</span>
</div>
<Fuehrungen />
</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">
+5 -3
View File
@@ -12,10 +12,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
try {
const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const { Kuppel, ArtFuehrung, SonderName, Spende, SpendeBetrag, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
const result = await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
Spende: Spende ?? null,
SpendeBetrag: SpendeBetrag ?? null,
Besucher: Besucher ?? 0,
beoIds: beoIds ?? [],
objekte: objekte ?? [],
@@ -24,7 +26,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
});
triggerBackup();
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, sofue: result?.sofue });
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
+4 -1
View File
@@ -30,10 +30,12 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const { Kuppel, ArtFuehrung, SonderName, Spende, SpendeBetrag, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const result = await phpdb.createLogbuch({
Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
Spende: Spende ?? null,
SpendeBetrag: SpendeBetrag ?? null,
Besucher: Besucher ?? 0,
beoIds: beoIds ?? [],
objekte: objekte ?? [],
@@ -43,6 +45,7 @@ export async function POST(request: NextRequest) {
});
triggerBackup();
// result enthält id und — bei Sonderführungen — den sofue-Status der Rückschreibung
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('POST /api/logbuch:', error);
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const { id } = await params;
const sofueId = parseInt(id);
if (!sofueId) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
try {
const { aktion, termin, grund } = await request.json();
// Das Kürzel für die Notiz stammt aus der Session, nicht aus dem Body.
const kuerzel = session.kuerzel;
if (aktion === 'absage') {
await phpdb.sofueAbsagen(sofueId, grund ?? '', kuerzel);
} else if (aktion === 'verschiebung') {
if (typeof termin !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:00$/.test(termin)) {
return NextResponse.json({ error: 'Neuer Termin fehlt oder ist ungültig' }, { status: 400 });
}
await phpdb.sofueVerschieben(sofueId, termin, grund ?? '', kuerzel);
} else {
return NextResponse.json({ error: 'Unbekannte Aktion' }, { status: 400 });
}
return NextResponse.json({ ok: true });
} catch (error: unknown) {
if (error instanceof Error && error.message.includes('404')) {
return NextResponse.json({ error: 'Führung nicht gefunden' }, { status: 404 });
}
console.error('POST /api/sofue/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import * as phpdb from '@/lib/phpdb';
export type { SofueTermin } 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 phpdb.getSofueTermine();
return NextResponse.json(rows);
} catch (error) {
console.error('GET /api/sofue:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+84 -8
View File
@@ -1,16 +1,92 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
export async function GET() {
const BASIS = 'https://stwwetter.fuerst-stuttgart.de/api/weather';
const ZONE = 'Europe/Berlin';
// Suchfenster um den gewünschten Zeitpunkt; die Station misst etwa alle 30 Sekunden.
const FENSTER_MIN = 20;
interface Messung {
datetime: string;
temperature: number;
humidity: number;
pressure: number;
}
/** Offset der Zone zum angegebenen Zeitpunkt in Minuten (im Sommer +120). */
function zonenOffset(zeitpunkt: Date): number {
const teile = new Intl.DateTimeFormat('en-US', {
timeZone: ZONE, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(zeitpunkt);
const w = Object.fromEntries(teile.map((t) => [t.type, t.value])) as Record<string, string>;
const alsUtc = Date.UTC(+w.year, +w.month - 1, +w.day, +w.hour % 24, +w.minute, +w.second);
return (alsUtc - zeitpunkt.getTime()) / 60000;
}
/**
* Lokale Zeit ("YYYY-MM-DDTHH:MM", Europe/Berlin) in echte UTC umrechnen.
* Die Wetter-API deutet Parameter ohne Zeitzone als UTC — ohne Umrechnung läge
* das Ergebnis im Sommer zwei Stunden daneben.
*/
function lokalNachUtc(lokal: string): Date {
const naiv = Date.parse(lokal + ':00Z');
// Zwei Durchgänge, damit auch die Zeitumstellungs-Nacht richtig landet.
let utc = naiv - zonenOffset(new Date(naiv)) * 60000;
utc = naiv - zonenOffset(new Date(utc)) * 60000;
return new Date(utc);
}
/** Ohne Millisekunden und ohne 'Z' — so erwartet die Wetter-API ihre Parameter. */
function alsParameter(d: Date): string {
return d.toISOString().slice(0, 19);
}
function aufbereiten(m: Messung) {
return {
temp: Math.round(m.temperature * 10) / 10,
feuchte: Math.round(m.humidity),
druck: Math.round(m.pressure),
};
}
export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const res = await fetch('https://stwwetter.fuerst-stuttgart.de/api/weather/latest', { cache: 'no-store' });
const zeit = new URL(request.url).searchParams.get('zeit');
// Ohne Zeitangabe wie bisher: der aktuelle Messwert.
if (!zeit) {
const res = await fetch(`${BASIS}/latest`, { cache: 'no-store' });
if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 });
return NextResponse.json(aufbereiten(await res.json()));
}
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(zeit)) {
return NextResponse.json({ error: 'Parameter zeit ungültig (YYYY-MM-DDTHH:MM)' }, { status: 400 });
}
const ziel = lokalNachUtc(zeit);
const von = new Date(ziel.getTime() - FENSTER_MIN * 60000);
const bis = new Date(ziel.getTime() + FENSTER_MIN * 60000);
const res = await fetch(
`${BASIS}/range?start=${alsParameter(von)}&end=${alsParameter(bis)}&limit=200`,
{ cache: 'no-store' }
);
if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 });
const data = await res.json();
const temp = Math.round(data.temperature * 10) / 10;
const feuchte = Math.round(data.humidity);
const druck = Math.round(data.pressure);
return NextResponse.json({ temp, feuchte, druck });
const messungen: Messung[] = await res.json();
if (!Array.isArray(messungen) || messungen.length === 0) {
// Kommt bei künftigen Zeitpunkten und bei Ausfällen der Station vor.
return NextResponse.json({ error: 'Keine Wetterdaten zu diesem Zeitpunkt' }, { status: 404 });
}
const naechste = messungen.reduce((a, b) =>
Math.abs(Date.parse(a.datetime) - ziel.getTime()) <= Math.abs(Date.parse(b.datetime) - ziel.getTime()) ? a : b
);
return NextResponse.json(aufbereiten(naechste));
}
+222
View File
@@ -0,0 +1,222 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import TimeInput from './TimeInput';
interface SofueTermin {
id: number;
wtermin: string;
verein: string;
name: string;
vorname: string;
anzahl: number;
mitarbeiter: string | null;
bemerkung: string | null;
}
type Aktion = 'absage' | 'verschiebung';
function gruppe(t: SofueTermin): string {
if (t.verein.trim()) return t.verein.trim();
return [t.vorname, t.name].map((s) => s.trim()).filter(Boolean).join(' ') || '—';
}
function datumDe(wtermin: string): string {
const [j, m, t] = wtermin.split(' ')[0].split('-');
return `${t}.${m}.${j}`;
}
function zeitDe(wtermin: string): string {
return (wtermin.split(' ')[1] ?? '').slice(0, 5);
}
export default function Fuehrungen() {
const [termine, setTermine] = useState<SofueTermin[] | null>(null);
const [ladefehler, setLadefehler] = useState(false);
// Offener Bestätigungsbereich: welche Führung, welche Aktion
const [offen, setOffen] = useState<{ id: number; aktion: Aktion } | null>(null);
const [grund, setGrund] = useState('');
const [neuesDatum, setNeuesDatum] = useState('');
const [neueZeit, setNeueZeit] = useState('20:00');
const [speichert, setSpeichert] = useState(false);
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null);
const laden = useCallback(() => {
fetch('/api/sofue')
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: SofueTermin[]) => { setTermine(d); setLadefehler(false); })
.catch(() => setLadefehler(true));
}, []);
useEffect(() => { laden(); }, [laden]);
function oeffnen(t: SofueTermin, aktion: Aktion) {
setOffen({ id: t.id, aktion });
setGrund('');
setNeuesDatum(t.wtermin.slice(0, 10));
setNeueZeit(zeitDe(t.wtermin) || '20:00');
setMeldung(null);
}
async function ausfuehren(t: SofueTermin, aktion: Aktion) {
if (aktion === 'verschiebung' && !neuesDatum) {
setMeldung({ art: 'fehler', text: 'Bitte ein neues Datum wählen.' });
return;
}
setSpeichert(true);
try {
const res = await fetch(`/api/sofue/${t.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aktion,
grund,
termin: aktion === 'verschiebung' ? `${neuesDatum} ${neueZeit}:00` : undefined,
}),
});
if (!res.ok) throw new Error();
setMeldung({
art: 'ok',
text: aktion === 'absage'
? `Führung „${gruppe(t)}" vom ${datumDe(t.wtermin)} als ausgefallen vermerkt.`
: `Führung „${gruppe(t)}" verschoben auf den ${datumDe(neuesDatum)} um ${neueZeit} Uhr.`,
});
setOffen(null);
laden();
} catch {
setMeldung({ art: 'fehler', text: 'Die Änderung konnte nicht gespeichert werden.' });
} finally {
setSpeichert(false);
}
}
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 tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const btnCls = 'px-2 py-1 text-xs font-medium rounded-lg border-2 transition-colors whitespace-nowrap';
const inputCls = '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';
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Kommende zugesagte Sonderführungen. Fällt eine Führung aus oder wird sie verschoben,
kann das hier vermerkt werden ein Logbuch-Eintrag ist dafür nicht nötig.
</p>
{meldung && (
<div
className={
'text-sm rounded-lg px-3 py-2 border ' +
(meldung.art === 'ok'
? 'text-green-700 bg-green-50 border-green-300'
: 'text-red-700 bg-red-50 border-red-300')
}
>
{meldung.text}
</div>
)}
{ladefehler && (
<div className="text-sm text-red-600">Die Termine konnten nicht geladen werden.</div>
)}
{!termine && !ladefehler && <div className="text-gray-500 text-sm py-4">Lade Termine...</div>}
{termine && termine.length === 0 && (
<div className="text-gray-500 text-sm py-4">Keine kommenden Sonderführungen.</div>
)}
{termine && termine.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className={thCls}>Termin</th>
<th className={thCls}>Gruppe</th>
<th className={`${thCls} text-center`}>Pers.</th>
<th className={thCls}>BEO</th>
<th className={`${thCls} text-center`}>Aktion</th>
</tr>
</thead>
<tbody>
{termine.map((t) => (
<tr key={t.id} className="hover:bg-gray-50 align-top">
<td className={`${tdCls} whitespace-nowrap`}>
<div>{datumDe(t.wtermin)}</div>
<div className="text-xs text-gray-500">{zeitDe(t.wtermin)} Uhr</div>
</td>
<td className={tdCls}>{gruppe(t)}</td>
<td className={`${tdCls} text-center tabular-nums`}>{t.anzahl}</td>
<td className={tdCls}>{t.mitarbeiter || '—'}</td>
<td className={tdCls}>
{offen?.id === t.id ? (
<div className="space-y-2">
{offen.aktion === 'verschiebung' && (
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-gray-600">Neuer Termin</label>
<input
type="date"
value={neuesDatum}
onChange={(e) => setNeuesDatum(e.target.value)}
className={inputCls}
/>
<TimeInput value={neueZeit} onChange={setNeueZeit} className="w-24" />
</div>
)}
<input
type="text"
value={grund}
onChange={(e) => setGrund(e.target.value.slice(0, 150))}
maxLength={150}
placeholder="Grund (optional)"
className={`${inputCls} w-full`}
/>
<div className="flex gap-2">
<button
type="button"
disabled={speichert}
onClick={() => ausfuehren(t, offen.aktion)}
className={`${btnCls} border-[#85B7D7] bg-[#85B7D7] text-black hover:bg-[#6a9fc5] disabled:opacity-50`}
>
{speichert
? 'Speichern...'
: offen.aktion === 'absage'
? 'Wirklich absagen'
: 'Verschieben'}
</button>
<button
type="button"
onClick={() => setOffen(null)}
className={`${btnCls} border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200`}
>
Abbrechen
</button>
</div>
</div>
) : (
<div className="flex gap-2 justify-center">
<button
type="button"
onClick={() => oeffnen(t, 'absage')}
className={`${btnCls} border-red-300 text-red-700 hover:bg-red-50`}
>
Ausgefallen
</button>
<button
type="button"
onClick={() => oeffnen(t, 'verschiebung')}
className={`${btnCls} border-blue-300 text-blue-700 hover:bg-blue-50`}
>
Verschoben
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+118 -18
View File
@@ -1,8 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN, ARTEN_MAP, artLabel } from '@/types/logbuch';
import type { Kuppel, ArtFuehrung, SpendeArt, BeoOption, SelectedObjekt, SofueStatus, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN, ARTEN_MAP, artLabel, SPENDE_ARTEN, spendeLabel } from '@/types/logbuch';
import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector';
import CustomSelect from './CustomSelect';
@@ -12,7 +12,8 @@ interface Props {
kuppel: Kuppel;
currentUserBeo: BeoOption;
editEntry?: LogbuchEintrag | null;
onSaved: () => void;
/** sofue: Ergebnis der Rückschreibung ins Sonderführungs-Register, sofern erfolgt. */
onSaved: (sofue?: SofueStatus | null) => void;
}
function toLocalDatetimeValue(isoOrDatetime: string): string {
@@ -46,21 +47,26 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState('');
const [sonderName, setSonderName] = useState('');
const [spende, setSpende] = useState<SpendeArt | ''>('');
const [spendeBetragRaw, setSpendeBetragRaw] = 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);
const [wetterHinweis, setWetterHinweis] = useState('');
const editEntryRef = useRef(editEntry);
editEntryRef.current = editEntry;
const formRef = useRef<HTMLFormElement>(null);
const prevArtRef = useRef<ArtFuehrung | null>(null);
const prevEndeRef = useRef<string | null>(null);
function focusNext(current: HTMLElement) {
if (!formRef.current) return;
const fields = Array.from(
formRef.current.querySelectorAll<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), #art-select'
'input:not([disabled]), textarea:not([disabled]), #art-select, #spende-select'
)
);
const idx = fields.indexOf(current);
@@ -83,6 +89,45 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {});
}, []);
// Ändert der Benutzer Datum oder Endzeit, wird das Wetter zu diesem Zeitpunkt
// nachgeladen. Der erste Lauf wird übersprungen: beim Anlegen liefert bereits der
// Effekt oben den aktuellen Wert, beim Bearbeiten stehen die gespeicherten Werte drin.
useEffect(() => {
if (prevEndeRef.current === null) {
prevEndeRef.current = ende;
return;
}
if (prevEndeRef.current === ende) return;
prevEndeRef.current = ende;
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(ende)) return;
let verworfen = false;
fetch(`/api/wetter?zeit=${encodeURIComponent(ende)}`)
.then(async (r) => {
if (r.status === 404) throw new Error('keine');
if (!r.ok) throw new Error('fehler');
return r.json() as Promise<Wetter>;
})
.then((w) => {
if (verworfen) return;
setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) });
setTempRaw(String(w.temp));
setWetterHinweis('');
})
.catch((e: Error) => {
// Bestehende Werte stehen lassen — ein leeres Formular wäre schlechter als alte Zahlen.
if (!verworfen) {
setWetterHinweis(
e.message === 'keine'
? 'Keine Wetterdaten zu diesem Zeitpunkt — Werte bitte selbst eintragen.'
: 'Wetterdaten konnten nicht geladen werden.'
);
}
});
return () => { verworfen = true; };
}, [ende]);
useEffect(() => {
if (editEntry) {
setArtFuehrung(editEntry.ArtFuehrung);
@@ -91,6 +136,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBesucher(editEntry.Besucher ?? '');
setBemerkungen(editEntry.Bemerkungen ?? '');
setSonderName(editEntry.SonderName ?? '');
setSpende(editEntry.Spende ?? '');
setSpendeBetragRaw(editEntry.SpendeBetrag != null ? String(editEntry.SpendeBetrag) : '');
if (editEntry.WetterTemp !== null) {
const t = editEntry.WetterTemp ?? 0;
setWetter({ temp: t, feuchte: Math.round(editEntry.WetterFeuchte ?? 0), druck: Math.round(editEntry.WetterDruck ?? 0) });
@@ -105,6 +152,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setObjekte([]);
setBemerkungen('');
setSonderName('');
setSpende('');
setSpendeBetragRaw('');
setBesucher('');
}
}, [editEntry, currentUserBeo]);
@@ -137,12 +186,20 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
}
}, [editEntry]);
// Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung
// Objekte, Besucher und Spende zurücksetzen beim Wechsel der Art der Führung
useEffect(() => {
const vorherigeArt = prevArtRef.current;
prevArtRef.current = artFuehrung;
setObjekte([]);
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setBesucher('');
}
// Nicht beim ersten Lauf leeren — sonst überschreibt der Start-Wert 'RF'
// die aus editEntry vorbelegte Spende.
if (vorherigeArt !== null && artFuehrung !== 'SF') {
setSpende('');
setSpendeBetragRaw('');
}
}, [artFuehrung]);
async function handleSubmit(e: React.FormEvent) {
@@ -162,6 +219,15 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setError('Start- und Endzeit sind identisch.');
return;
}
const spendeBetrag = parseFloat(spendeBetragRaw.replace(',', '.'));
if (artFuehrung === 'SF' && spende === '') {
setError('Bitte Spende auswählen.');
return;
}
if (artFuehrung === 'SF' && spende === 'bar' && !(spendeBetrag > 0)) {
setError('Bitte Spendenbetrag eingeben.');
return;
}
setSaving(true);
@@ -169,6 +235,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
Kuppel: kuppel,
ArtFuehrung: artFuehrung,
SonderName: artFuehrung === 'SF' ? sonderName : null,
Spende: artFuehrung === 'SF' && spende !== '' ? spende : null,
SpendeBetrag: artFuehrung === 'SF' && spende === 'bar' ? spendeBetrag : null,
Beginn: beginn,
Ende: ende,
Besucher: besucher === '' ? 0 : besucher,
@@ -188,6 +256,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
const daten: { sofue?: SofueStatus } = await res.json().catch(() => ({}));
setSuccess(true);
setTimeout(() => setSuccess(false), 5000);
if (!editEntry) {
@@ -197,8 +266,13 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBeos([currentUserBeo]);
setObjekte([]);
setBemerkungen('');
setSonderName('');
setSpende('');
setSpendeBetragRaw('');
}
onSaved();
// Die Meldung zur Sonderführung gehört nach oben: beim Bearbeiten wird dieses
// Formular durch onSaved() neu gemountet und verlöre eigenen State sofort.
onSaved(daten.sofue ?? null);
} catch {
setError('Fehler beim Speichern. Bitte erneut versuchen.');
} finally {
@@ -280,18 +354,41 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
)}
</div>
{/* Name/Gruppe bei Sonderführung */}
{/* Name/Gruppe und Spende bei Sonderführung */}
{artFuehrung === 'SF' && (
<div>
<label className={labelCls}>Name / Gruppe</label>
<input
type="text"
value={sonderName}
onChange={(e) => setSonderName(e.target.value.slice(0, 200))}
maxLength={200}
placeholder="Name oder Gruppe der Sonderführung"
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[160px]">
<label className={labelCls}>Name / Gruppe</label>
<input
type="text"
value={sonderName}
onChange={(e) => setSonderName(e.target.value.slice(0, 200))}
maxLength={200}
placeholder="Name oder Gruppe der Sonderführung"
className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="shrink-0 w-32">
<label className={labelCls}>Spende</label>
<CustomSelect
id="spende-select"
placeholder={spende === '' ? 'bitte wählen' : spendeLabel(spende)}
options={SPENDE_ARTEN.map((s) => ({ value: s, label: spendeLabel(s) }))}
onChange={(v) => setSpende(v as SpendeArt)}
/>
</div>
{spende === 'bar' && (
<div className="shrink-0">
<label className={labelCls}>Betrag ()</label>
<input
type="text"
inputMode="decimal"
value={spendeBetragRaw}
onChange={(e) => setSpendeBetragRaw(e.target.value)}
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
)}
</div>
)}
@@ -363,6 +460,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
/>
</div>
</div>
{wetterHinweis && (
<div className="mt-1 text-xs text-amber-700">{wetterHinweis}</div>
)}
</div>
{error && (
@@ -388,7 +488,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{editEntry && (
<button
type="button"
onClick={onSaved}
onClick={() => onSaved()}
className="w-full sm:w-auto px-6 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
>
Abbrechen
+8
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import { spendeLabel } from '@/types/logbuch';
interface Props {
kuppel: Kuppel;
@@ -255,6 +256,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
<td className={cell}>
<div>{e.ArtFuehrung}</div>
{e.SonderName && <div className="text-xs text-gray-500">{e.SonderName}</div>}
{e.Spende && (
<div className="text-xs text-gray-500">
Spende: {spendeLabel(e.Spende)}
{e.Spende === 'bar' && e.SpendeBetrag != null &&
' ' + Number(e.SpendeBetrag).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
</div>
)}
</td>
<td className={`${cell} text-center`}>{e.Besucher || ''}</td>
<td className={cell}>
+36 -4
View File
@@ -1,4 +1,4 @@
import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, Wetter } from '@/types/logbuch';
import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, SofueStatus, Wetter } from '@/types/logbuch';
const PHP_DB_URL = process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php';
@@ -85,6 +85,8 @@ export interface CreateLogbuchData {
Kuppel: string;
ArtFuehrung: string;
SonderName?: string | null;
Spende?: string | null;
SpendeBetrag?: number | null;
Beginn: string;
Ende: string;
Besucher?: number;
@@ -95,7 +97,7 @@ export interface CreateLogbuchData {
created_by: number;
}
export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number }> {
export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number; sofue?: SofueStatus }> {
return call('LB_CREATE_LOGBUCH', data);
}
@@ -104,8 +106,8 @@ export async function updateLogbuch(
userId: number,
userRole: string,
data: Omit<CreateLogbuchData, 'created_by'>
): Promise<void> {
await call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data });
): Promise<{ sofue?: SofueStatus }> {
return call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data });
}
export async function deleteLogbuch(
@@ -156,6 +158,36 @@ export async function getFahrkosten(ab: string, bis: string): Promise<Fahrkosten
return call('LB_FAHRKOSTEN', { ab, bis });
}
// ---- Sonderführungs-Register (SoFue2) ----
export interface SofueTermin {
id: number;
wtermin: string;
verein: string;
name: string;
vorname: string;
anzahl: number;
mitarbeiter: string | null;
bemerkung: string | null;
}
export async function getSofueTermine(): Promise<SofueTermin[]> {
return call('LB_SOFUE_TERMINE');
}
export async function sofueAbsagen(id: number, grund: string, kuerzel: string): Promise<void> {
await call('LB_SOFUE_ABSAGEN', { id, grund, kuerzel });
}
export async function sofueVerschieben(
id: number,
termin: string,
grund: string,
kuerzel: string
): Promise<void> {
await call('LB_SOFUE_VERSCHIEBEN', { id, termin, grund, kuerzel });
}
export interface StatistikResult {
monthly: {
monat: number;
+7
View File
@@ -0,0 +1,7 @@
-- Migration: Spende-Feld für Sonderführungen (Version 1.12.0)
-- Spende wird nur bei ArtFuehrung = 'SF' gesetzt, sonst NULL.
-- SpendeBetrag ist nur bei Spende = 'bar' gefüllt.
-- Hinweis: In der DB steht der ASCII-Schlüssel 'ueberw', angezeigt wird "überw.".
ALTER TABLE logbuch
ADD COLUMN Spende ENUM('bar','ueberw','kasse','keine') DEFAULT NULL,
ADD COLUMN SpendeBetrag DECIMAL(8,2) DEFAULT NULL;
+18 -128
View File
@@ -1,16 +1,15 @@
{
"name": "logbuch",
"version": "1.7.5",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "logbuch",
"version": "1.7.5",
"version": "1.14.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
"mysql2": "^3.22.3",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -23,6 +22,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"marked": "^18.0.9",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1575,6 +1575,7 @@
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2434,15 +2435,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axe-core": {
"version": "4.11.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
@@ -2831,15 +2823,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3696,15 +3679,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3969,22 +3943,6 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4307,12 +4265,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4920,12 +4872,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4949,21 +4895,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4974,6 +4905,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5038,40 +4982,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz",
"integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5770,12 +5680,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6007,21 +5911,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6509,6 +6398,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
+5 -2
View File
@@ -1,9 +1,11 @@
{
"name": "logbuch",
"version": "1.11.0",
"version": "1.15.0",
"private": true,
"scripts": {
"dev": "next dev",
"anleitung": "node scripts/build-anleitung.mjs",
"prebuild": "node scripts/build-anleitung.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -11,7 +13,7 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.2.2",
"next": "16.1.6",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -23,6 +25,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"marked": "^18.0.9",
"tailwindcss": "^4",
"typescript": "^5"
}
-349
View File
@@ -1,349 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.65;
color: #1a1a2e;
background: #f4f6fb;
}
.page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff;
padding: 2.5rem 2rem 2rem;
border-radius: 12px;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
gap: 1.2rem;
}
header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
nav.toc {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.4rem 1.8rem;
margin-bottom: 2.5rem;
}
nav.toc h2 {
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
}
nav.toc ol {
list-style: none; counter-reset: toc;
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
}
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before {
content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-weight: 700; min-width: 1.4rem;
}
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; }
section {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.8rem 2rem;
margin-bottom: 1.5rem;
}
h2.section-title {
font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7;
display: flex; align-items: center; gap: 0.6rem;
}
h2.section-title .num {
background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
width: 1.6rem; height: 1.6rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
h3 {
font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
}
h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; }
ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
th {
background: #eef2fa; color: #2e4e8a; font-weight: 700;
text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
}
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; }
code {
background: #eef2fa; color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
}
.callout {
background: #eef6fb; border-left: 4px solid #85b7d7;
border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
margin: 0.9rem 0; font-size: 0.9rem;
}
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; }
.back-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
text-decoration: none; cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; }
}
@media print {
body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; }
header { border-radius: 0; background: #1a2d5a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
section { border: 1px solid #ccc; break-inside: avoid; }
nav.toc { break-after: page; }
a { color: inherit; text-decoration: none; }
.back-btn { display: none; }
}
</style>
</head>
<body>
<div class="page">
<a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header>
<div class="star"></div>
<div>
<h1>Bedienungsanleitung</h1>
<p>Führungsbuch Sternwarte Welzheim</p>
</div>
</header>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
<ol>
<li><a href="#s1">Anmelden</a></li>
<li><a href="#s2">Grundaufbau der App</a></li>
<li><a href="#s3">Eintrag erfassen (Tab „Eingabe")</a></li>
<li><a href="#s4">Einträge einsehen und verwalten (Tab „Liste")</a></li>
<li><a href="#s5">Jahresstatistik (Tab „Statistik")</a></li>
<li><a href="#s6">Drucken</a></li>
<li><a href="#s7">Administration (nur Admins)</a></li>
</ol>
</nav>
<!-- ── 1. Anmelden ── -->
<section id="s1">
<h2 class="section-title"><span class="num">1</span> Anmelden</h2>
<p>Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.</p>
<ul>
<li><strong>Kürzel</strong> das persönliche BEO-Kürzel (z. B. <code>RXF</code>)</li>
<li><strong>Passwort</strong> individuell gesetztes Passwort</li>
</ul>
<div class="callout warn">
Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet <code>welzheim</code>.
</div>
</section>
<!-- ── 2. Grundaufbau ── -->
<section id="s2">
<h2 class="section-title"><span class="num">2</span> Grundaufbau der App</h2>
<h3>Kuppel-Auswahl</h3>
<p>Oben befinden sich vier Reiter für die vier Kuppeln:</p>
<table>
<thead><tr><th>Reiter</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>West</td><td>West-Kuppel</td></tr>
<tr><td>Ost</td><td>Ost-Kuppel</td></tr>
<tr><td>Süd</td><td>Süd-Kuppel</td></tr>
<tr><td>Pluto</td><td>Pluto-Kuppel</td></tr>
</tbody>
</table>
<p>Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.</p>
<h3>Funktions-Tabs</h3>
<p>Unterhalb der Kuppelauswahl gibt es drei Tabs:</p>
<table>
<thead><tr><th>Tab</th><th>Funktion</th></tr></thead>
<tbody>
<tr><td><strong>Eingabe</strong></td><td>Neuen Eintrag anlegen oder bestehenden bearbeiten</td></tr>
<tr><td><strong>Liste</strong></td><td>Alle Einträge monatsweise ansehen, bearbeiten oder löschen</td></tr>
<tr><td><strong>Statistik</strong></td><td>Jahresübersicht Besucher und Führungen</td></tr>
</tbody>
</table>
</section>
<!-- ── 3. Eingabe ── -->
<section id="s3">
<h2 class="section-title"><span class="num">3</span> Eintrag erfassen (Tab „Eingabe")</h2>
<h3>Pflichtfelder</h3>
<p><strong>Art der Führung</strong> Auswahl aus dem Dropdown:</p>
<table>
<thead><tr><th>Anzeige</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Reguläre Führung</td><td>Reguläre öffentliche Führung</td></tr>
<tr><td>Sonderführung</td><td>Für Gruppen, Schulen etc.</td></tr>
<tr><td>Sonnenführung</td><td>Führung mit Sonnenbeobachtung</td></tr>
<tr><td>Privatführung</td><td>Private Führung</td></tr>
<tr><td>BEOS</td><td>BEO-Sitzung (keine Besucher/Objekte)</td></tr>
<tr><td>TD</td><td>Treff/Diskussion (keine Besucher/Objekte)</td></tr>
<tr><td>Beobachtung</td><td>Reine Beobachtung ohne Führung</td></tr>
<tr><td>ToT</td><td>Teleskop ohne Termin</td></tr>
<tr><td>Sonstiges</td><td>Sonstige Veranstaltung</td></tr>
</tbody>
</table>
<p><strong>Datum</strong> Datum der Veranstaltung (Standardwert: heute).</p>
<p><strong>Startzeit / Endzeit</strong> Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. <code>8</code> oder <code>23</code>) und das Feld verlassen, werden die Minuten automatisch auf <code>00</code> gesetzt.</p>
<p><strong>Besucher</strong> Anzahl der Besucher (nicht sichtbar bei BEOS und TD).</p>
<h3>Optionale Felder</h3>
<p><strong>Name / Gruppe</strong> erscheint nur bei Sonderführung; Name der Gruppe oder Person.</p>
<p><strong>BEOs</strong> beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.</p>
<p><strong>Beobachtete Objekte</strong> nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:</p>
<ul>
<li>Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.</li>
<li>Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.</li>
<li>Noch unbekannte Objekte einfach eintippen am Ende der Dropdown-Liste erscheint dann <strong>+ „[Name]" hinzufügen</strong>. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.</li>
<li>Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.</li>
</ul>
<p><strong>Bemerkungen</strong> freier Text, max. 500 Zeichen.</p>
<p><strong>Wetterdaten</strong> Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. <code>-5</code>) können direkt eingegeben werden.</p>
<h3>Eintrag speichern</h3>
<p>Schaltfläche <strong>Eintrag speichern</strong> unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.</p>
<div class="callout">
Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau.
</div>
<h3>Eintrag bearbeiten</h3>
<p>Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung <strong>Änderungen speichern</strong> klicken oder mit <strong>Abbrechen</strong> verwerfen.</p>
</section>
<!-- ── 4. Liste ── -->
<section id="s4">
<h2 class="section-title"><span class="num">4</span> Einträge einsehen und verwalten (Tab „Liste")</h2>
<h3>Werkzeugleiste</h3>
<p>Die Werkzeugleiste oben in der Liste enthält in einer Zeile:</p>
<ul>
<li><strong>Monatsnavigation</strong> Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; <strong>Aktueller Monat</strong> springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).</li>
<li><strong>Suchfeld</strong> Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.</li>
<li><strong>🖨 Drucken</strong> siehe Abschnitt <a href="#s6">Drucken</a>.</li>
</ul>
<h3>Tabelleninhalt</h3>
<p>Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte <strong>fettgedruckt</strong> und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.</p>
<h3>Eintrag bearbeiten</h3>
<p>Stift-Symbol ✎ rechts in der Zeile.</p>
<h3>Eintrag löschen</h3>
<p>× rechts in der Zeile es erscheint ein Bestätigungsdialog.</p>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong>.</div>
<h3>Seitennavigation</h3>
<p>Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.</p>
</section>
<!-- ── 5. Statistik ── -->
<section id="s5">
<h2 class="section-title"><span class="num">5</span> Jahresstatistik (Tab „Statistik")</h2>
<p>Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.</p>
<ul>
<li><strong>Jahr</strong> oben links änderbar (Eingabefeld).</li>
<li>Darunter vier Kennzahlen-Kacheln:
<ul>
<li>Kumulierte Besucher des Jahres für die gewählte Kuppel</li>
<li>Führungstage des Jahres für die gewählte Kuppel</li>
<li>Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)</li>
<li>Führungstage für die gesamte Sternwarte</li>
</ul>
</li>
</ul>
<div class="callout">
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
</div>
</section>
<!-- ── 6. Drucken ── -->
<section id="s6">
<h2 class="section-title"><span class="num">6</span> Drucken</h2>
<p><strong>Im Tab „Liste"</strong> Schaltfläche <strong>🖨 Drucken</strong> oben rechts in der Werkzeugleiste:</p>
<ul>
<li>Es werden <strong>alle Einträge des aktuell gewählten Monats</strong> geladen (nicht nur die angezeigte Seite).</li>
<li>Die Reihenfolge ist beim Ausdruck <strong>chronologisch</strong> (ältester Eintrag zuerst).</li>
<li>Navigations- und Aktionselemente werden ausgeblendet; oben erscheint eine Kopfzeile mit Kuppelname und Druckdatum.</li>
<li>Seitenformat: A4 Hochformat, Rand 1,5 cm.</li>
</ul>
<p><strong>Im Tab „Statistik"</strong> ebenfalls eine <strong>🖨 Drucken</strong>-Schaltfläche für die Jahresstatistik.</p>
</section>
<!-- ── 7. Administration ── -->
<section id="s7">
<h2 class="section-title"><span class="num">7</span> Administration <small style="font-size:0.7em;font-weight:500;color:#6b7a9b;">(nur Admins)</small></h2>
<p>Erreichbar über die Schaltfläche <strong>Admin</strong> oben rechts (nur für Benutzer mit Admin-Rolle sichtbar). Die Admin-Seite hat zwei Tabs:</p>
<h3>Benutzerverwaltung</h3>
<p>Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.</p>
<p><strong>Passwort zurücksetzen</strong> Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort <code>welzheim</code> verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.</p>
<h3>Objektverwaltung</h3>
<p>Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.</p>
<ul>
<li><strong>Neues Objekt anlegen</strong> Feld unten ausfüllen und <strong>Hinzufügen</strong> klicken.</li>
<li><strong>Objekt umbenennen</strong> Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li>
<li><strong>Objekt löschen</strong> × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
</ul>
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
</section>
</div>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
<!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>{{H1}}</h1>
<p>{{SUBTITLE}}</p>
</div>
</header>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
<ol>
{{TOC}}
</ol>
</nav>
{{SECTIONS}}
</div>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
/**
* Erzeugt public/anleitung.html aus ANLEITUNG.md.
*
* ANLEITUNG.md ist die einzige Quelle des Handbuchs; die HTML-Fassung wird nie von Hand
* bearbeitet (sie steht in .gitignore). Aufruf: `npm run anleitung` — läuft zusätzlich
* automatisch vor jedem `npm run build` (prebuild) und damit auch im Docker-Image.
*
* Abbildung Markdown → HTML:
* # Titel → Kopfzeile der Seite (vor „–" = H1, danach = Untertitel)
* ## Inhaltsverzeichnis → nav.toc, die Sprungziele werden auf #s1..#sN umgeschrieben
* ## N. Titel → <section id="sN"> mit nummeriertem Abschnittskopf
* --- → entfällt (reine Trennlinie in der Markdown-Ansicht)
* > Text → Hinweisbox .callout
* > [!WARNUNG] Text → .callout warn (auch [!WARNING])
* > [!ACHTUNG] Text → .callout danger (auch [!CAUTION])
* > [!HINWEIS] Text → .callout (auch [!NOTE])
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { marked } from 'marked';
const wurzel = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUELLE = join(wurzel, 'ANLEITUNG.md');
const VORLAGE = join(wurzel, 'scripts', 'anleitung.template.html');
const ZIEL = join(wurzel, 'public', 'anleitung.html');
const ALERTS = {
WARNUNG: 'callout warn', WARNING: 'callout warn',
ACHTUNG: 'callout danger', CAUTION: 'callout danger', DANGER: 'callout danger',
HINWEIS: 'callout', NOTE: 'callout',
};
/** Blockquotes werden zu Hinweisboxen, optional mit Variante über [!WARNUNG] o. ä. */
function calloutsUmschreiben(html) {
return html.replace(/<blockquote>\s*([\s\S]*?)\s*<\/blockquote>/g, (_, inhalt) => {
let klasse = 'callout';
const markierung = inhalt.match(/^<p>\s*\[!([A-ZÄÖÜ]+)\]\s*/);
if (markierung && ALERTS[markierung[1]]) {
klasse = ALERTS[markierung[1]];
inhalt = inhalt.replace(markierung[0], '<p>');
}
// Eine einzelne Textzeile braucht kein <p> — das spart eine Zeile Abstand in der Box.
const einzeln = inhalt.match(/^<p>([\s\S]*?)<\/p>$/);
return `<div class="${klasse}">${einzeln ? einzeln[1] : inhalt}</div>`;
});
}
/** Markdown-Sprungziele wie #7-drucken auf die Abschnitts-IDs #s7 umschreiben. */
function ankerUmschreiben(html) {
return html.replace(/href="#(\d+)-[^"]*"/g, (_, nummer) => `href="#s${nummer}"`);
}
function einrücken(html, stufe = ' ') {
return html.split('\n').map((z) => (z.trim() ? stufe + z : z)).join('\n');
}
const markdown = readFileSync(QUELLE, 'utf8');
// 1. Seitentitel aus der H1
const h1 = markdown.match(/^#\s+(.+)$/m);
if (!h1) throw new Error('ANLEITUNG.md: H1-Überschrift (# Titel) fehlt');
const [titelKopf, titelUnter = ''] = h1[1].split(/\s+[–—-]\s+/, 2);
// 2. In Blöcke je H2 zerlegen
const bloecke = markdown
.split(/^##\s+/m)
.slice(1)
.map((b) => {
const umbruch = b.indexOf('\n');
return {
titel: b.slice(0, umbruch).trim(),
rumpf: b.slice(umbruch + 1).replace(/^\s*---\s*$/gm, '').trim(),
};
});
// 3. Inhaltsverzeichnis: die Nummern der Listenpunkte werden zu #s<N>
const tocBlock = bloecke.find((b) => b.titel.toLowerCase() === 'inhaltsverzeichnis');
if (!tocBlock) throw new Error('ANLEITUNG.md: Abschnitt „## Inhaltsverzeichnis" fehlt');
const toc = [...tocBlock.rumpf.matchAll(/^\s*(\d+)\.\s*\[([^\]]+)\]/gm)]
.map(([, nr, text]) => ` <li><a href="#s${nr}">${text}</a></li>`)
.join('\n');
if (!toc) throw new Error('ANLEITUNG.md: Inhaltsverzeichnis enthält keine Einträge');
// 4. Nummerierte Abschnitte
const abschnitte = [];
for (const block of bloecke) {
const kopf = block.titel.match(/^(\d+)\.\s+(.*)$/);
if (!kopf) continue; // Inhaltsverzeichnis und alles ohne Nummer
const [, nummer, titel] = kopf;
const rumpf = ankerUmschreiben(calloutsUmschreiben(marked.parse(block.rumpf)));
abschnitte.push(
` <!-- ── ${nummer}. ${titel} ── -->\n` +
` <section id="s${nummer}">\n` +
` <h2 class="section-title"><span class="num">${nummer}</span> ${titel}</h2>\n` +
`${einrücken(rumpf.trim())}\n` +
` </section>`
);
}
if (!abschnitte.length) throw new Error('ANLEITUNG.md: keine nummerierten Abschnitte (## 1. …) gefunden');
const html = readFileSync(VORLAGE, 'utf8')
.replace('{{H1}}', titelKopf.trim())
.replace('{{SUBTITLE}}', titelUnter.trim())
.replace('{{TOC}}', toc)
.replace('{{SECTIONS}}', abschnitte.join('\n\n'));
writeFileSync(ZIEL, html, 'utf8');
console.log(`anleitung.html erzeugt — ${abschnitte.length} Abschnitte, ${html.length} Zeichen`);
+25
View File
@@ -21,6 +21,29 @@ export function artLabel(a: ArtFuehrung): string {
return ARTEN_MAP[a] || a;
}
export type SpendeArt = 'bar' | 'ueberw' | 'kasse' | 'keine';
export const SPENDE_MAP: Record<SpendeArt, string> = {
bar: 'bar',
ueberw: 'überw.',
kasse: 'kasse',
keine: 'keine',
};
export const SPENDE_ARTEN = Object.keys(SPENDE_MAP) as SpendeArt[];
export function spendeLabel(s: SpendeArt): string {
return SPENDE_MAP[s] || s;
}
/** Ergebnis der Rückschreibung einer Sonderführung ins Register SoFue2. */
export interface SofueStatus {
status: 'ok' | 'kein_termin' | 'fehler' | 'uebersprungen';
id?: number;
wtermin?: string;
datum?: string;
}
export interface BeoOption {
ID: number;
Kuerzel: string;
@@ -48,6 +71,8 @@ export interface LogbuchEintrag {
Kuppel: Kuppel;
ArtFuehrung: ArtFuehrung;
SonderName: string | null;
Spende: SpendeArt | null;
SpendeBetrag: number | null;
Beginn: string;
Ende: string;
Besucher: number;