From b3fbc391b188df537daa7f5157d93e61777ec5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sat, 8 Aug 2026 18:03:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Tab=20=E2=80=9EF=C3=BChrungen"=20f?= =?UTF-8?q?=C3=BCr=20ausgefallene=20und=20verschobene=20Sonderf=C3=BChrung?= =?UTF-8?q?en=20=E2=80=94=20Version=201.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für jeden angemeldeten BEO. Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar. Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs, Design in scripts/anleitung.template.html), automatisch vor jedem Build. public/anleitung.html ist deshalb nicht mehr in git. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 2 + .gitignore | 3 + ANLEITUNG.md | 51 ++++- CLAUDE.md | 8 + app/MainClient.tsx | 17 +- app/api/sofue/[id]/route.ts | 37 ++++ app/api/sofue/route.ts | 18 ++ components/Fuehrungen.tsx | 222 ++++++++++++++++++++ lib/phpdb.ts | 30 +++ package-lock.json | 146 ++----------- package.json | 7 +- public/anleitung.html | 353 -------------------------------- scripts/anleitung.template.html | 168 +++++++++++++++ scripts/build-anleitung.mjs | 109 ++++++++++ 14 files changed, 674 insertions(+), 497 deletions(-) create mode 100644 app/api/sofue/[id]/route.ts create mode 100644 app/api/sofue/route.ts create mode 100644 components/Fuehrungen.tsx delete mode 100644 public/anleitung.html create mode 100644 scripts/anleitung.template.html create mode 100644 scripts/build-anleitung.mjs diff --git a/.dockerignore b/.dockerignore index e2a23cf..c6785af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,5 @@ node_modules .next *.md +# Quelle der Anleitung — wird beim Build zu public/anleitung.html verarbeitet +!ANLEITUNG.md diff --git a/.gitignore b/.gitignore index 0296c91..39ef458 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ANLEITUNG.md b/ANLEITUNG.md index 1902fcb..62c3afc 100644 --- a/ANLEITUNG.md +++ b/ANLEITUNG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 2ccca8b..002e122 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` 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. diff --git a/app/MainClient.tsx b/app/MainClient.tsx index c5d91e5..31b3de6 100644 --- a/app/MainClient.tsx +++ b/app/MainClient.tsx @@ -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('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(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 */}
- {(['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) => ( ))}
@@ -264,6 +265,16 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) { )} + {/* Führungen-Tab: kommende Sonderführungen absagen oder verschieben */} + {activeTab === 'fuehrungen' && ( +
+
+ Kommende Sonderführungen +
+ +
+ )} + {/* Fahrkosten-Tab */} {activeTab === 'fahrkosten' && (role?.includes('admin') || role?.includes('master')) && (
diff --git a/app/api/sofue/[id]/route.ts b/app/api/sofue/[id]/route.ts new file mode 100644 index 0000000..f692357 --- /dev/null +++ b/app/api/sofue/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/sofue/route.ts b/app/api/sofue/route.ts new file mode 100644 index 0000000..84ec643 --- /dev/null +++ b/app/api/sofue/route.ts @@ -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 }); + } +} diff --git a/components/Fuehrungen.tsx b/components/Fuehrungen.tsx new file mode 100644 index 0000000..0c172fb --- /dev/null +++ b/components/Fuehrungen.tsx @@ -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(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 ( +
+

+ 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. +

+ + {meldung && ( +
+ {meldung.text} +
+ )} + + {ladefehler && ( +
Die Termine konnten nicht geladen werden.
+ )} + {!termine && !ladefehler &&
Lade Termine...
} + + {termine && termine.length === 0 && ( +
Keine kommenden Sonderführungen.
+ )} + + {termine && termine.length > 0 && ( +
+ + + + + + + + + + + + {termine.map((t) => ( + + + + + + + + ))} + +
TerminGruppePers.BEOAktion
+
{datumDe(t.wtermin)}
+
{zeitDe(t.wtermin)} Uhr
+
{gruppe(t)}{t.anzahl}{t.mitarbeiter || '—'} + {offen?.id === t.id ? ( +
+ {offen.aktion === 'verschiebung' && ( +
+ + setNeuesDatum(e.target.value)} + className={inputCls} + /> + +
+ )} + setGrund(e.target.value.slice(0, 150))} + maxLength={150} + placeholder="Grund (optional)" + className={`${inputCls} w-full`} + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+ ); +} diff --git a/lib/phpdb.ts b/lib/phpdb.ts index 987fd16..56fdcca 100644 --- a/lib/phpdb.ts +++ b/lib/phpdb.ts @@ -158,6 +158,36 @@ export async function getFahrkosten(ab: string, bis: string): Promise { + return call('LB_SOFUE_TERMINE'); +} + +export async function sofueAbsagen(id: number, grund: string, kuerzel: string): Promise { + await call('LB_SOFUE_ABSAGEN', { id, grund, kuerzel }); +} + +export async function sofueVerschieben( + id: number, + termin: string, + grund: string, + kuerzel: string +): Promise { + await call('LB_SOFUE_VERSCHIEBEN', { id, termin, grund, kuerzel }); +} + export interface StatistikResult { monthly: { monat: number; diff --git a/package-lock.json b/package-lock.json index b18c134..e479fa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 5b637ca..d517800 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/public/anleitung.html b/public/anleitung.html deleted file mode 100644 index 5b8ab63..0000000 --- a/public/anleitung.html +++ /dev/null @@ -1,353 +0,0 @@ - - - - - - Bedienungsanleitung – Führungsbuch Sternwarte Welzheim - - - -
- - ← Zurück zum Führungsbuch - -
-
-
-

Bedienungsanleitung

-

Führungsbuch Sternwarte Welzheim

-
-
- - - - -
-

1 Anmelden

-

Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.

-
    -
  • Kürzel – das persönliche BEO-Kürzel (z. B. RXF)
  • -
  • Passwort – individuell gesetztes Passwort
  • -
-
- Wurde das Passwort noch nicht geändert, muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet welzheim. -
-
- - -
-

2 Grundaufbau der App

- -

Kuppel-Auswahl

-

Oben befinden sich vier Reiter für die vier Kuppeln:

- - - - - - - - -
ReiterBedeutung
WestWest-Kuppel
OstOst-Kuppel
SüdSüd-Kuppel
PlutoPluto-Kuppel
-

Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.

- -

Funktions-Tabs

-

Unterhalb der Kuppelauswahl gibt es drei Tabs:

- - - - - - - -
TabFunktion
EingabeNeuen Eintrag anlegen oder bestehenden bearbeiten
ListeAlle Einträge monatsweise ansehen, bearbeiten oder löschen
StatistikJahresübersicht Besucher und Führungen
-
- - -
-

3 Eintrag erfassen (Tab „Eingabe")

- -

Pflichtfelder

-

Art der Führung – Auswahl aus dem Dropdown:

- - - - - - - - - - - - - -
AnzeigeBedeutung
Reguläre FührungReguläre öffentliche Führung
SonderführungFür Gruppen, Schulen etc.
SonnenführungFührung mit Sonnenbeobachtung
PrivatführungPrivate Führung
BEOSBEO-Sitzung (keine Besucher/Objekte)
TDTreff/Diskussion (keine Besucher/Objekte)
BeobachtungReine Beobachtung ohne Führung
ToTTeleskop ohne Termin
SonstigesSonstige Veranstaltung
- -

Datum – Datum der Veranstaltung (Standardwert: heute).

- -

Startzeit / Endzeit – Uhrzeit von Beginn und Ende. Die Startzeit ist beim Laden leer und erhält automatisch den Fokus; die Endzeit wird auf die aktuelle Uhrzeit aufgerundet auf die nächste 5-Minuten-Marke vorausgefüllt. Wird nur die Stundenzahl eingegeben (z. B. 8 oder 23) und das Feld verlassen, werden die Minuten automatisch auf 00 gesetzt.

- -

Besucher – Anzahl der Besucher (nicht sichtbar bei BEOS und TD).

- -

Optionale Felder

- -

Name / Gruppe – erscheint nur bei Sonderführung; Name der Gruppe oder Person.

- -

Spende – erscheint nur bei Sonderführung, rechts neben Name / Gruppe. Auswahl zwischen bar, überw., kasse und keine; die Angabe ist bei Sonderführungen verpflichtend. Bei bar erscheint zusätzlich ein Feld Betrag (€), in das der erhaltene Betrag eingetragen werden muss (Komma oder Punkt als Dezimaltrennzeichen, z. B. 25,50).

- -

Sonderführungen werden automatisch nachgetragen. Beim Speichern eines Eintrags der Art Sonderführung wird die passende Führung im Sonderführungs-Register über das Datum gesucht und dort als stattgefunden markiert; Besucherzahl, Spende und Bemerkungen werden mit übernommen. Ein Nachtragen im Beo-Formular entfällt damit. Gibt es an dem Tag mehrere Führungen, wird die zeitlich nächstgelegene genommen. Wird gar keine gefunden, erscheint über dem Formular eine gelbe Meldung — der Logbuch-Eintrag ist trotzdem gespeichert, nur das Register muss dann von Hand gepflegt werden.

- -

BEOs – beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.

- -

Beobachtete Objekte – nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:

-
    -
  • Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.
  • -
  • Das Dropdown bleibt nach der Auswahl offen, sodass mehrere Objekte ohne erneutes Öffnen hintereinander ausgewählt werden können. Durch Klick außerhalb schließt es sich.
  • -
  • Noch unbekannte Objekte einfach eintippen – am Ende der Dropdown-Liste erscheint dann + „[Name]" hinzufügen. Ein Klick (oder Enter bei leerem Suchergebnis) legt das Objekt neu an.
  • -
  • Ausgewählte Objekte erscheinen als grüne Chips; × entfernt sie.
  • -
- -

Bemerkungen – freier Text, max. 500 Zeichen.

- -

Wetterdaten – Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. -5) können direkt eingegeben werden.

- -

Eintrag speichern

-

Schaltfläche Eintrag speichern unten im Formular. Eine grüne Meldung bestätigt die Speicherung; das Formular wird zurückgesetzt.

-
- Auf Desktop-Geräten erscheinen unterhalb des Formulars die letzten 5 Einträge der aktuellen Kuppel als kompakte Vorschau. -
- -

Eintrag bearbeiten

-

Im Tab „Liste" das Stift-Symbol (✎) anklicken. Die App springt zum Tab „Eingabe" und zeigt einen gelben Hinweis „Eintrag bearbeiten (ID …)". Nach der Änderung Änderungen speichern klicken oder mit Abbrechen verwerfen.

-
- - -
-

4 Einträge einsehen und verwalten (Tab „Liste")

- -

Werkzeugleiste

-

Die Werkzeugleiste oben in der Liste enthält in einer Zeile:

-
    -
  • Monatsnavigation – Pfeiltasten ← → wechseln den Monat; Monatseingabe im Feld direkt möglich; Aktueller Monat springt zurück auf den laufenden Monat. Zukünftige Monate können nicht gewählt werden. Während einer aktiven Suche wird die Monatsnavigation ausgeblendet (der Platz bleibt frei, damit sich nichts verschiebt).
  • -
  • Suchfeld – Freitextsuche über alle Einträge des Führungsbuchs (Bemerkungen, Objekte und BEOs). Die Ergebnisse erscheinen monatsübergreifend in absteigender Datumsreihenfolge. Das × im Suchfeld löscht die Eingabe und kehrt zur Monatsansicht zurück.
  • -
  • 🖨 Drucken – siehe Abschnitt Drucken.
  • -
- -

Tabelleninhalt

-

Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (Beginn–Ende), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte fettgedruckt und steht an erster Stelle. Wetterdaten werden nur angezeigt, wenn mindestens ein Wert ungleich null ist.

- -

Eintrag bearbeiten

-

Stift-Symbol ✎ rechts in der Zeile.

- -

Eintrag löschen

-

× rechts in der Zeile – es erscheint ein Bestätigungsdialog.

-
Das Löschen ist unwiderruflich.
- -

Seitennavigation

-

Bei mehr als 10 Einträgen erscheinen Vor/Zurück-Schaltflächen am unteren Rand.

-
- - -
-

5 Jahresstatistik (Tab „Statistik")

-

Zeigt eine Monatstabelle mit Anzahl der Führungen und Besuchern, aufgeschlüsselt nach Art der Führung.

-
    -
  • Jahr oben links änderbar (Eingabefeld).
  • -
  • Darunter vier Kennzahlen-Kacheln: -
      -
    • Kumulierte Besucher des Jahres für die gewählte Kuppel
    • -
    • Führungstage des Jahres für die gewählte Kuppel
    • -
    • Kumulierte Besucher für die gesamte Sternwarte (alle Kuppeln)
    • -
    • Führungstage für die gesamte Sternwarte
    • -
    -
  • -
-
- Über den Button 📊 Grafik kann die Statistik-Grafik in einem separaten Fenster aufgerufen werden. -
-
- - -
-

6 Drucken

- -

Im Tab „Liste" – Schaltfläche 🖨 Drucken oben rechts in der Werkzeugleiste:

-
    -
  • Es werden alle Einträge des aktuell gewählten Monats geladen (nicht nur die angezeigte Seite).
  • -
  • Die Reihenfolge ist beim Ausdruck chronologisch (ältester Eintrag zuerst).
  • -
  • Navigations- und Aktionselemente werden ausgeblendet; oben erscheint eine Kopfzeile mit Kuppelname und Druckdatum.
  • -
  • Seitenformat: A4 Hochformat, Rand 1,5 cm.
  • -
- -

Im Tab „Statistik" – ebenfalls eine 🖨 Drucken-Schaltfläche für die Jahresstatistik.

-
- - -
-

7 Administration (nur Admins)

-

Erreichbar über die Schaltfläche Admin oben rechts (nur für Benutzer mit Admin-Rolle sichtbar). Die Admin-Seite hat zwei Tabs:

- -

Benutzerverwaltung

-

Die Tabelle zeigt alle BEOs mit Kürzel, Name, Vorname, Rolle und Passwortstatus.

-

Passwort zurücksetzen – Schaltfläche „Zurücksetzen" neben dem jeweiligen Benutzer. Das Passwort wird auf NULL gesetzt; beim nächsten Login muss der Benutzer das Standard-Passwort welzheim verwenden und wird anschließend aufgefordert, ein neues Passwort zu vergeben.

- -

Objektverwaltung

-

Zeigt alle bekannten Objekte mit ID, Name und Datum der letzten Verwendung.

-
    -
  • Neues Objekt anlegen – Feld unten ausfüllen und Hinzufügen klicken.
  • -
  • Objekt umbenennen – Stift-Symbol ✎ in der Zeile anklicken, Namen ändern und mit Speichern bestätigen oder mit Abbrechen verwerfen.
  • -
  • Objekt löschen – × in der Zeile; es erscheint ein Bestätigungsdialog.
  • -
-
Das Löschen ist unwiderruflich und entfernt das Objekt aus allen bestehenden Führungsbucheinträgen.
-
- -
- - diff --git a/scripts/anleitung.template.html b/scripts/anleitung.template.html new file mode 100644 index 0000000..4f7faa2 --- /dev/null +++ b/scripts/anleitung.template.html @@ -0,0 +1,168 @@ + + + + + + Bedienungsanleitung – Führungsbuch Sternwarte Welzheim + + + +
+ + ← Zurück zum Führungsbuch + +
+
+
+

{{H1}}

+

{{SUBTITLE}}

+
+
+ + + +{{SECTIONS}} + +
+ + diff --git a/scripts/build-anleitung.mjs b/scripts/build-anleitung.mjs new file mode 100644 index 0000000..7004452 --- /dev/null +++ b/scripts/build-anleitung.mjs @@ -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 →
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(/
\s*([\s\S]*?)\s*<\/blockquote>/g, (_, inhalt) => { + let klasse = 'callout'; + const markierung = inhalt.match(/^

\s*\[!([A-ZÄÖÜ]+)\]\s*/); + if (markierung && ALERTS[markierung[1]]) { + klasse = ALERTS[markierung[1]]; + inhalt = inhalt.replace(markierung[0], '

'); + } + // Eine einzelne Textzeile braucht kein

— das spart eine Zeile Abstand in der Box. + const einzeln = inhalt.match(/^

([\s\S]*?)<\/p>$/); + return `

${einzeln ? einzeln[1] : inhalt}
`; + }); +} + +/** 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 +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]) => `
  • ${text}
  • `) + .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( + ` \n` + + `
    \n` + + `

    ${nummer} ${titel}

    \n` + + `${einrücken(rumpf.trim())}\n` + + `
    ` + ); +} +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`);