chore: feat/fuehrungen → main (Version 1.14.0)

This commit is contained in:
2026-08-08 18:03:50 +02:00
14 changed files with 674 additions and 497 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
+40 -11
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) |
---
@@ -80,6 +82,9 @@ Unterhalb der Kuppelauswahl gibt es drei Tabs:
**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.
@@ -120,7 +125,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
@@ -132,7 +137,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
@@ -140,7 +147,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.
@@ -155,7 +182,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.
@@ -168,7 +195,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).
@@ -186,5 +213,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.
+8
View File
@@ -37,6 +37,14 @@ Next.js 16 App Router application. All pages are server components; interactive
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/`.
## 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
`output: 'standalone'` is set in `next.config.ts` for Docker. The MySQL container name in production is `db` — set `DB_HOST=db` in the server's environment.
+14 -3
View File
@@ -7,6 +7,7 @@ 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 {
@@ -36,7 +37,7 @@ 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');
@@ -140,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
@@ -155,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>
@@ -264,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">
+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 });
}
}
+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>
);
}
+30
View File
@@ -158,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;
+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.13.0",
"version": "1.14.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"
}
-353
View File
@@ -1,353 +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>Spende</strong> erscheint nur bei Sonderführung, rechts neben Name / Gruppe. Auswahl zwischen <code>bar</code>, <code>überw.</code>, <code>kasse</code> und <code>keine</code>; die Angabe ist bei Sonderführungen verpflichtend. Bei <code>bar</code> erscheint zusätzlich ein Feld <strong>Betrag (€)</strong>, in das der erhaltene Betrag eingetragen werden muss (Komma oder Punkt als Dezimaltrennzeichen, z. B. <code>25,50</code>).</p>
<p><strong>Sonderführungen werden automatisch nachgetragen.</strong> Beim Speichern eines Eintrags der Art Sonderführung wird die passende Führung im Sonderführungs-Register über das Datum gesucht und dort als <em>stattgefunden</em> 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.</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`);