chore: feat/fuehrungen → main (Version 1.14.0)
This commit is contained in:
@@ -5,3 +5,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
*.md
|
*.md
|
||||||
|
# Quelle der Anleitung — wird beim Build zu public/anleitung.html verarbeitet
|
||||||
|
!ANLEITUNG.md
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
backup.git/
|
backup.git/
|
||||||
DB_BACKUP/
|
DB_BACKUP/
|
||||||
|
|
||||||
|
# wird aus ANLEITUNG.md erzeugt, nicht von Hand gepflegt
|
||||||
|
public/anleitung.html
|
||||||
|
|||||||
+40
-11
@@ -6,9 +6,10 @@
|
|||||||
2. [Grundaufbau der App](#2-grundaufbau-der-app)
|
2. [Grundaufbau der App](#2-grundaufbau-der-app)
|
||||||
3. [Eintrag erfassen (Tab „Eingabe")](#3-eintrag-erfassen-tab-eingabe)
|
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)
|
4. [Einträge einsehen und verwalten (Tab „Liste")](#4-einträge-einsehen-und-verwalten-tab-liste)
|
||||||
5. [Jahresstatistik (Tab „Statistik")](#5-jahresstatistik-tab-statistik)
|
5. [Ausgefallene und verschobene Führungen (Tab „Führungen")](#5-ausgefallene-und-verschobene-führungen-tab-führungen)
|
||||||
6. [Drucken](#6-drucken)
|
6. [Jahresstatistik (Tab „Statistik")](#6-jahresstatistik-tab-statistik)
|
||||||
7. [Administration (nur Admins)](#7-administration-nur-admins)
|
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`)
|
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
|
||||||
- **Passwort**: individuell gesetztes Passwort
|
- **Passwort**: individuell gesetztes Passwort
|
||||||
|
|
||||||
Wurde das Passwort noch nicht geändert, 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
|
### Funktions-Tabs
|
||||||
|
|
||||||
Unterhalb der Kuppelauswahl gibt es drei Tabs:
|
Unterhalb der Kuppelauswahl gibt es vier Tabs:
|
||||||
|
|
||||||
| Tab | Funktion |
|
| Tab | Funktion |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| **Eingabe** | Neuen Eintrag anlegen oder bestehenden bearbeiten |
|
| **Eingabe** | Neuen Eintrag anlegen oder bestehenden bearbeiten |
|
||||||
| **Liste** | Alle Einträge monatsweise ansehen, bearbeiten oder löschen |
|
| **Liste** | Alle Einträge monatsweise ansehen, bearbeiten oder löschen |
|
||||||
| **Statistik** | Jahresübersicht Besucher und Führungen |
|
| **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`).
|
**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ü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
|
> 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.
|
> 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).
|
- **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.
|
- **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
|
### Tabelleninhalt
|
||||||
|
|
||||||
@@ -132,7 +137,9 @@ Stift-Symbol ✎ rechts in der Zeile.
|
|||||||
|
|
||||||
### Eintrag löschen
|
### 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
|
### 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.
|
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.
|
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).
|
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.
|
- **Neues Objekt anlegen**: Feld unten ausfüllen und **Hinzufügen** klicken.
|
||||||
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
|
- **Objekt umbenennen**: Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit **Speichern** bestätigen oder mit **Abbrechen** verwerfen.
|
||||||
- **Objekt löschen**: × in der Zeile – es erscheint ein Bestätigungsdialog. Das Löschen ist **unwiderruflich** und entfernt das Objekt aus allen bestehenden 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
`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
@@ -7,6 +7,7 @@ import LogbuchForm from '@/components/LogbuchForm';
|
|||||||
import LogbuchList from '@/components/LogbuchList';
|
import LogbuchList from '@/components/LogbuchList';
|
||||||
import Statistik from '@/components/Statistik';
|
import Statistik from '@/components/Statistik';
|
||||||
import Fahrkosten from '@/components/Fahrkosten';
|
import Fahrkosten from '@/components/Fahrkosten';
|
||||||
|
import Fuehrungen from '@/components/Fuehrungen';
|
||||||
import packageJson from '@/package.json';
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
function datumDe(iso?: string): string {
|
function datumDe(iso?: string): string {
|
||||||
@@ -36,7 +37,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
||||||
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
|
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
|
||||||
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik' | 'fahrkosten'>('eingabe');
|
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'fuehrungen' | 'statistik' | 'fahrkosten'>('eingabe');
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
|
||||||
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
|
const [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 */}
|
{/* Eingabe/Liste/Statistik-Tabs */}
|
||||||
<div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden">
|
<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'))
|
.filter((tab) => tab !== 'fahrkosten' || role?.includes('admin') || role?.includes('master'))
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -155,7 +156,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'}
|
{tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : tab === 'fuehrungen' ? 'Führungen' : tab === 'statistik' ? 'Statistik' : 'Fahrkosten'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +265,16 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
</div>
|
</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 */}
|
{/* Fahrkosten-Tab */}
|
||||||
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
|
{activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
|
||||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -158,6 +158,36 @@ export async function getFahrkosten(ab: string, bis: string): Promise<Fahrkosten
|
|||||||
return call('LB_FAHRKOSTEN', { ab, bis });
|
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 {
|
export interface StatistikResult {
|
||||||
monthly: {
|
monthly: {
|
||||||
monat: number;
|
monat: number;
|
||||||
|
|||||||
Generated
+18
-128
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.5",
|
"version": "1.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.5",
|
"version": "1.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"mysql2": "^3.22.3",
|
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"marked": "^18.0.9",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -1575,6 +1575,7 @@
|
|||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2434,15 +2435,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/axe-core": {
|
||||||
"version": "4.11.3",
|
"version": "4.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
|
||||||
@@ -2831,15 +2823,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -3696,15 +3679,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
@@ -3969,22 +3943,6 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4307,12 +4265,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -4920,12 +4872,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -4949,21 +4895,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4974,6 +4905,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5038,40 +4982,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -5770,12 +5680,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -6007,21 +5911,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -6509,6 +6398,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
+5
-2
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"anleitung": "node scripts/build-anleitung.mjs",
|
||||||
|
"prebuild": "node scripts/build-anleitung.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"marked": "^18.0.9",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (Beginn–Ende), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte <strong>fettgedruckt</strong> und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.</p>
|
|
||||||
|
|
||||||
<h3>Eintrag bearbeiten</h3>
|
|
||||||
<p>Stift-Symbol ✎ rechts in der Zeile.</p>
|
|
||||||
|
|
||||||
<h3>Eintrag löschen</h3>
|
|
||||||
<p>× rechts in der Zeile – es erscheint ein Bestätigungsdialog.</p>
|
|
||||||
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong>.</div>
|
|
||||||
|
|
||||||
<h3>Seitennavigation</h3>
|
|
||||||
<p>Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── 5. Statistik ── -->
|
|
||||||
<section id="s5">
|
|
||||||
<h2 class="section-title"><span class="num">5</span> Jahresstatistik (Tab „Statistik")</h2>
|
|
||||||
<p>Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Jahr</strong> oben links änderbar (Eingabefeld).</li>
|
|
||||||
<li>Darunter vier Kennzahlen-Kacheln:
|
|
||||||
<ul>
|
|
||||||
<li>Kumulierte Besucher des Jahres für die gewählte Kuppel</li>
|
|
||||||
<li>Führungstage des Jahres für die gewählte Kuppel</li>
|
|
||||||
<li>Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)</li>
|
|
||||||
<li>Führungstage für die gesamte Sternwarte</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="callout">
|
|
||||||
Über den Button <strong>📊 Grafik</strong> kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── 6. Drucken ── -->
|
|
||||||
<section id="s6">
|
|
||||||
<h2 class="section-title"><span class="num">6</span> Drucken</h2>
|
|
||||||
|
|
||||||
<p><strong>Im Tab „Liste"</strong> – Schaltfläche <strong>🖨 Drucken</strong> oben rechts in der Werkzeugleiste:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Es werden <strong>alle Einträge des aktuell gewählten Monats</strong> geladen (nicht nur die angezeigte Seite).</li>
|
|
||||||
<li>Die Reihenfolge ist beim Ausdruck <strong>chronologisch</strong> (ältester Eintrag zuerst).</li>
|
|
||||||
<li>Navigations- und Aktionselemente werden ausgeblendet; oben erscheint eine Kopfzeile mit Kuppelname und Druckdatum.</li>
|
|
||||||
<li>Seitenformat: A4 Hochformat, Rand 1,5 cm.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><strong>Im Tab „Statistik"</strong> – ebenfalls eine <strong>🖨 Drucken</strong>-Schaltfläche für die Jahresstatistik.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── 7. Administration ── -->
|
|
||||||
<section id="s7">
|
|
||||||
<h2 class="section-title"><span class="num">7</span> Administration <small style="font-size:0.7em;font-weight:500;color:#6b7a9b;">(nur Admins)</small></h2>
|
|
||||||
<p>Erreichbar über die Schaltfläche <strong>Admin</strong> oben rechts (nur für Benutzer mit Admin-Rolle sichtbar). Die Admin-Seite hat zwei Tabs:</p>
|
|
||||||
|
|
||||||
<h3>Benutzerverwaltung</h3>
|
|
||||||
<p>Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.</p>
|
|
||||||
<p><strong>Passwort zurücksetzen</strong> – Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort <code>welzheim</code> verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.</p>
|
|
||||||
|
|
||||||
<h3>Objektverwaltung</h3>
|
|
||||||
<p>Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Neues Objekt anlegen</strong> – Feld unten ausfüllen und <strong>Hinzufügen</strong> klicken.</li>
|
|
||||||
<li><strong>Objekt umbenennen</strong> – Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit <strong>Speichern</strong> bestätigen oder mit <strong>Abbrechen</strong> verwerfen.</li>
|
|
||||||
<li><strong>Objekt löschen</strong> – × in der Zeile; es erscheint ein Bestätigungsdialog.</li>
|
|
||||||
</ul>
|
|
||||||
<div class="callout danger">Das Löschen ist <strong>unwiderruflich</strong> und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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>
|
||||||
@@ -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`);
|
||||||
Reference in New Issue
Block a user