v1.7.4: Suche in Listenansicht (Bemerkungen, Objekte, BEOs)

Suchfeld in der Toolbar der Listenansicht: Suche über alle Einträge
der Kuppel in Bemerkungen, Objekte und BEOs. Monatsauswahl, Suchfeld
und Drucken-Button in einer Zeile; Monatsauswahl wird bei aktiver
Suche unsichtbar aber platzhaltend ausgeblendet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 21:45:09 +02:00
parent d5ceff74be
commit 9e2f430d4a
5 changed files with 264 additions and 48 deletions
+165
View File
@@ -0,0 +1,165 @@
# Bedienungsanleitung Logbuch Sternwarte Welzheim
## Inhaltsverzeichnis
1. [Anmelden](#1-anmelden)
2. [Grundaufbau der App](#2-grundaufbau-der-app)
3. [Eintrag erfassen (Tab „Eingabe")](#3-eintrag-erfassen-tab-eingabe)
4. [Einträge einsehen und verwalten (Tab „Liste")](#4-einträge-einsehen-und-verwalten-tab-liste)
5. [Jahresstatistik (Tab „Statistik")](#5-jahresstatistik-tab-statistik)
6. [Drucken](#6-drucken)
7. [Administration (nur Admins)](#7-administration-nur-admins)
---
## 1. Anmelden
Die App ist passwortgeschützt. Beim ersten Aufruf erscheint die Anmeldeseite.
- **Kürzel**: das persönliche BEO-Kürzel (z. B. `RXF`)
- **Passwort**: individuell gesetztes Passwort
Wurde das Passwort noch nicht geändert (Anzeige „Standard"), muss nach dem ersten Login sofort ein neues Passwort vergeben werden. Das Standard-Passwort lautet `welzheim`.
---
## 2. Grundaufbau der App
### Kuppel-Auswahl
Oben befinden sich vier Reiter für die vier Kuppeln:
| Reiter | Bedeutung |
|--------|-----------|
| West | West-Kuppel |
| Ost | Ost-Kuppel |
| Süd | Süd-Kuppel |
| Pluto | Pluto-Kuppel |
Alle Einträge, Listen und Statistiken beziehen sich immer auf die gerade gewählte Kuppel.
### Funktions-Tabs
Unterhalb der Kuppelauswahl gibt es drei Tabs:
| Tab | Funktion |
|-----|----------|
| **Eingabe** | Neuen Eintrag anlegen oder bestehenden bearbeiten |
| **Liste** | Alle Einträge monatsweise ansehen, bearbeiten oder löschen |
| **Statistik** | Jahresübersicht Besucher und Führungen |
---
## 3. Eintrag erfassen (Tab „Eingabe")
### Pflichtfelder
**Art der Führung** Auswahl aus dem Dropdown:
| Kürzel | Bedeutung |
|--------|-----------|
| regulär | Reguläre öffentliche Führung |
| sonder | Sonderführung (für Gruppen, Schulen etc.) |
| sonnen | Sonnenführung |
| privat | Privatführung |
| BEOS | BEO-Sitzung (keine Besucher/Objekte) |
| TD | Treff/Diskussion (keine Besucher/Objekte) |
| Beobachtung | Reine Beobachtung ohne Führung |
| ToT | Teleskop ohne Termin |
| Sonstiges | Sonstige Veranstaltung |
**Datum** Datum der Veranstaltung (Standardwert: heute).
**Startzeit / Endzeit** Uhrzeit von Beginn und Ende. Die Startzeit wird beim Laden automatisch auf die aktuelle Uhrzeit gesetzt; die Endzeit ist zunächst leer und muss eingetragen werden.
**Besucher** Anzahl der Besucher (nicht bei BEOS und TD).
### Optionale Felder
**Name / Gruppe** erscheint nur bei Sonderführung; Name der Gruppe oder Person.
**BEOs** beteiligte Beobachter. Der eigene Name ist automatisch vorausgewählt. Weitere BEOs können über das Suchfeld hinzugefügt werden; ein Klick auf × entfernt sie wieder.
**Beobachtete Objekte** nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:
- Bekannte Objekte durch Eintippen suchen und aus dem Dropdown auswählen.
- 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.
### 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")
### Monatsnavigation
Mit den Pfeiltasten ← → den Monat wechseln oder direkt im Monatsfeld eingeben. **Aktueller Monat** springt zurück auf den laufenden Monat.
### Tabelleninhalt
Die Tabelle zeigt pro Eintrag: Datum, Uhrzeit (BeginnEnde), Art der Führung, Besucher, beteiligte BEOs, beobachtete Objekte, Bemerkungen und Wetterdaten. Der Ersteller des Eintrags ist in der BEO-Spalte **fettgedruckt** und steht an erster Stelle.
### 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 15 Einträgen im Monat 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
---
## 6. Drucken
Im Tab **Liste**: Schaltfläche **🖨 Drucken** oben rechts in der Liste.
- Es werden **alle Einträge des aktuell gewählten Monats** geladen (nicht nur die angezeigte Seite).
- Die Reihenfolge ist beim Ausdruck **chronologisch** (ältester Eintrag zuerst).
- Navigations- und Aktionselemente werden ausgeblendet; oben erscheint ein Kopfzeile mit Kuppelname und Druckdatum.
- Seitenformat: A4 Hochformat, Rand 1,5 cm.
Im Tab **Statistik**: ebenfalls eine **🖨 Drucken**-Schaltfläche für die Jahresstatistik.
---
---
## 7. Administration (nur Admins)
Erreichbar über die Schaltfläche **Admin** oben rechts (nur für Benutzer mit Admin-Rolle sichtbar).
### 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.
+31
View File
@@ -33,6 +33,37 @@ export async function GET(request: NextRequest) {
const month = searchParams.get('month') || ''; const month = searchParams.get('month') || '';
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC'; const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC';
const search = (searchParams.get('search') || '').trim();
if (search) {
const pattern = '%' + search + '%';
const searchParams2 = [kuppel, pattern, pattern, pattern];
const countSQL =
'SELECT COUNT(*) AS total FROM (' +
'SELECT l.ID FROM logbuch l' +
' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' +
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
' WHERE l.Kuppel = ?' +
' GROUP BY l.ID' +
" HAVING (MAX(l.Bemerkungen) LIKE ? OR GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') LIKE ? OR GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') LIKE ?)" +
') AS sub';
const listSQL = LIST_SQL +
' HAVING (MAX(l.Bemerkungen) LIKE ? OR BEOs LIKE ? OR Objekte LIKE ?)' +
` ORDER BY l.Beginn DESC LIMIT ${limit} OFFSET ${offset}`;
try {
const [countRows, entries] = await Promise.all([
query(countSQL, searchParams2) as Promise<{ total: number }[]>,
query(listSQL, searchParams2),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) {
console.error('GET /api/logbuch (search):', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
let listWhere = 'WHERE l.Kuppel = ?'; let listWhere = 'WHERE l.Kuppel = ?';
let countWhere = 'WHERE Kuppel = ?'; let countWhere = 'WHERE Kuppel = ?';
let params: (string | number | null)[] = [kuppel]; let params: (string | number | null)[] = [kuppel];
+65 -45
View File
@@ -59,20 +59,27 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null); const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
const [search, setSearch] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const printPending = useRef(false); const printPending = useRef(false);
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]); useEffect(() => {
const t = setTimeout(() => setActiveSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month, activeSearch]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
const offset = page * limit; const offset = page * limit;
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` + const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
(month ? `&month=${encodeURIComponent(month)}` : ''); (activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : ''));
fetch(url) fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); }) .then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); })
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); }); .catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
}, [kuppel, refreshKey, limit, page, month]); }, [kuppel, refreshKey, limit, page, month, activeSearch]);
useEffect(() => { useEffect(() => {
function onAfterPrint() { setPrintEntries(null); } function onAfterPrint() { setPrintEntries(null); }
@@ -108,32 +115,57 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
} }
} }
const monthNav = !compact && ( const toolbar = !compact && (
<div className="flex items-center gap-2 mb-3 print:hidden"> <div className="flex items-center gap-2 mb-3 print:hidden">
<button <div
onClick={() => setMonth((m) => prevMonth(m))} className="flex items-center gap-1 shrink-0"
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300" style={{ visibility: activeSearch ? 'hidden' : 'visible' }}
></button> >
<input
type="month"
value={month}
max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<button <button
onClick={() => setMonth(currentMonth())} onClick={() => setMonth((m) => prevMonth(m))}
className="text-sm text-blue-600 hover:underline" className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300"
> ></button>
Aktueller Monat <input
</button> type="month"
)} value={month}
max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<button
onClick={() => setMonth(currentMonth())}
className="text-sm text-blue-600 hover:underline ml-1"
>Aktueller Monat</button>
)}
</div>
<div className="relative flex-1 min-w-0 mx-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche in Bemerkungen, Objekte, BEOs…"
className="w-full px-3 py-1.5 pr-8 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
/>
{search ? (
<button
onClick={() => setSearch('')}
aria-label="Suche löschen"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm leading-none"
></button>
) : (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none text-sm">🔍</span>
)}
</div>
<button
onClick={handlePrint}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg shrink-0"
>🖨 Drucken</button>
</div> </div>
); );
@@ -150,27 +182,15 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold' ? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
: 'px-3 py-2 border border-gray-300'; : 'px-3 py-2 border border-gray-300';
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
const displayEntries = printEntries ?? entries; const displayEntries = printEntries ?? entries;
return ( return (
<div> <div>
{!compact && ( {toolbar}
<div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Einträge {kuppel}-Kuppel</span>
<button
onClick={handlePrint}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
</div>
)}
{monthNav}
{printHeader} {printHeader}
<div className="overflow-x-auto"> {loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && <div className="overflow-x-auto">
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}> <table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
<thead> <thead>
<tr className="bg-gray-100 text-left"> <tr className="bg-gray-100 text-left">
@@ -196,7 +216,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
{displayEntries.length === 0 ? ( {displayEntries.length === 0 ? (
<tr> <tr>
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center"> <td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
Keine Einträge für {monthLabel(month)}. {activeSearch ? `Keine Einträge für „${activeSearch}" gefunden.` : `Keine Einträge für ${monthLabel(month)}.`}
</td> </td>
</tr> </tr>
) : displayEntries.map((e) => ( ) : displayEntries.map((e) => (
@@ -257,7 +277,7 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>}
{!compact && total > limit && ( {!compact && total > limit && (
<div className="flex items-center justify-center gap-3 mt-3 print:hidden"> <div className="flex items-center justify-center gap-3 mt-3 print:hidden">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.3", "version": "1.7.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "logbuch", "name": "logbuch",
"version": "1.7.3", "version": "1.7.4",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.3", "version": "1.7.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",