'use client'; import { useEffect, useRef, useState } from 'react'; import type { Kuppel, ArtFuehrung, SpendeArt, BeoOption, SelectedObjekt, SofueStatus, Wetter, LogbuchEintrag } from '@/types/logbuch'; import { ARTEN, ARTEN_MAP, artLabel, SPENDE_ARTEN, spendeLabel } from '@/types/logbuch'; import BeoSelector from './BeoSelector'; import ObjektSelector from './ObjektSelector'; import CustomSelect from './CustomSelect'; import TimeInput from './TimeInput'; interface Props { kuppel: Kuppel; currentUserBeo: BeoOption; editEntry?: LogbuchEintrag | null; /** sofue: Ergebnis der Rückschreibung ins Sonderführungs-Register, sofern erfolgt. */ onSaved: (sofue?: SofueStatus | null) => void; } function toLocalDatetimeValue(isoOrDatetime: string): string { if (!isoOrDatetime) return ''; return isoOrDatetime.slice(0, 16); } function todayDate(): string { const now = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; } function nowRounded5(): string { const now = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); const total = now.getHours() * 60 + now.getMinutes(); const rounded = Math.ceil(total / 5) * 5; return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`; } function tagVerschieben(datum: string, tage: number): string { const d = new Date(datum + 'T12:00:00Z'); // Mittag — unempfindlich gegen Zeitzonen d.setUTCDate(d.getUTCDate() + tage); return d.toISOString().slice(0, 10); } /** * Beginn aus dem Ende ableiten: Ist die Startzeit später als die Endzeit, ging die * Führung über Mitternacht und hat am Vortag begonnen. Anker ist das Ende — der * Zeitpunkt, zu dem das Logbuch ausgefüllt wird, und damit der verlässliche. */ function beginnAusEnde(ende: string, startZeit: string): string { const datum = startZeit > ende.slice(11, 16) ? tagVerschieben(ende.slice(0, 10), -1) : ende.slice(0, 10); return `${datum}T${startZeit}`; } /** Ende aus dem Beginn ableiten: Endzeit vor der Startzeit ⇒ Folgetag. */ function endeAusBeginn(beginn: string, endZeit: string): string { const startZeit = beginn.slice(11, 16); const datum = startZeit && endZeit < startZeit ? tagVerschieben(beginn.slice(0, 10), 1) : beginn.slice(0, 10); return `${datum}T${endZeit}`; } /** "2026-08-09T00:30" → "09.08." */ function datumKurz(zeitpunkt: string): string { const [j, m, t] = zeitpunkt.slice(0, 10).split('-'); return t && m && j ? `${t}.${m}.` : zeitpunkt.slice(0, 10); } /** Dauer in Minuten; null, solange keine Startzeit eingetragen ist. */ function dauerMinuten(beginn: string, ende: string): number | null { if (!beginn.slice(11, 16) || !ende.slice(11, 16)) return null; const d = (Date.parse(ende + ':00Z') - Date.parse(beginn + ':00Z')) / 60000; return Number.isFinite(d) ? d : null; } const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD']; const SONNE_ART: ArtFuehrung = 'SonF'; export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) { const [artFuehrung, setArtFuehrung] = useState('RF'); const [beginn, setBeginn] = useState(todayDate()); const [ende, setEnde] = useState(todayDate() + 'T' + nowRounded5()); const [besucher, setBesucher] = useState(''); const [beos, setBeos] = useState([currentUserBeo]); const [objekte, setObjekte] = useState([]); const [bemerkungen, setBemerkungen] = useState(''); const [sonderName, setSonderName] = useState(''); const [spende, setSpende] = useState(''); const [spendeBetragRaw, setSpendeBetragRaw] = useState(''); const [wetter, setWetter] = useState({ temp: 0, feuchte: 0, druck: 0 }); const [tempRaw, setTempRaw] = useState('0'); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [wetterHinweis, setWetterHinweis] = useState(''); const editEntryRef = useRef(editEntry); editEntryRef.current = editEntry; const formRef = useRef(null); const prevArtRef = useRef(null); const prevEndeRef = useRef(null); function focusNext(current: HTMLElement) { if (!formRef.current) return; const fields = Array.from( formRef.current.querySelectorAll( 'input:not([disabled]), textarea:not([disabled]), #art-select, #spende-select' ) ); const idx = fields.indexOf(current); if (idx >= 0) fields[(idx + 1) % fields.length]?.focus(); } const dauer = dauerMinuten(beginn, ende); const ueberMitternacht = beginn.slice(11, 16) !== '' && beginn.slice(0, 10) !== ende.slice(0, 10); const dauerZuLang = dauer !== null && dauer > 12 * 60; const dauerText = dauer === null ? '' : `${Math.floor(dauer / 60)}:${String(Math.round(dauer % 60)).padStart(2, '0')}`; const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const isSonne = artFuehrung === SONNE_ART; useEffect(() => { fetch('/api/wetter') .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((w: Wetter) => { if (!editEntryRef.current) { setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) }); setTempRaw(String(w.temp)); } }) .catch(() => {}); }, []); // Ändert der Benutzer Datum oder Endzeit, wird das Wetter zu diesem Zeitpunkt // nachgeladen. Der erste Lauf wird übersprungen: beim Anlegen liefert bereits der // Effekt oben den aktuellen Wert, beim Bearbeiten stehen die gespeicherten Werte drin. useEffect(() => { if (prevEndeRef.current === null) { prevEndeRef.current = ende; return; } if (prevEndeRef.current === ende) return; prevEndeRef.current = ende; if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(ende)) return; let verworfen = false; fetch(`/api/wetter?zeit=${encodeURIComponent(ende)}`) .then(async (r) => { if (r.status === 404) throw new Error('keine'); if (!r.ok) throw new Error('fehler'); return r.json() as Promise; }) .then((w) => { if (verworfen) return; setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) }); setTempRaw(String(w.temp)); setWetterHinweis(''); }) .catch((e: Error) => { // Bestehende Werte stehen lassen — ein leeres Formular wäre schlechter als alte Zahlen. if (!verworfen) { setWetterHinweis( e.message === 'keine' ? 'Keine Wetterdaten zu diesem Zeitpunkt — Werte bitte selbst eintragen.' : 'Wetterdaten konnten nicht geladen werden.' ); } }); return () => { verworfen = true; }; }, [ende]); // Vorbelegen beim Bearbeiten, sonst leeres Formular. currentUserBeo muss vom // Aufrufer stabil gehalten werden, sonst setzt jedes Rendern die Eingaben zurück. useEffect(() => { if (editEntry) { setArtFuehrung(editEntry.ArtFuehrung); setBeginn(toLocalDatetimeValue(editEntry.Beginn)); setEnde(toLocalDatetimeValue(editEntry.Ende)); setBesucher(editEntry.Besucher ?? ''); setBemerkungen(editEntry.Bemerkungen ?? ''); setSonderName(editEntry.SonderName ?? ''); setSpende(editEntry.Spende ?? ''); setSpendeBetragRaw(editEntry.SpendeBetrag != null ? String(editEntry.SpendeBetrag) : ''); if (editEntry.WetterTemp !== null) { const t = editEntry.WetterTemp ?? 0; setWetter({ temp: t, feuchte: Math.round(editEntry.WetterFeuchte ?? 0), druck: Math.round(editEntry.WetterDruck ?? 0) }); setTempRaw(String(t)); } } else { setArtFuehrung('RF'); setBeginn(todayDate()); setEnde(todayDate() + 'T' + nowRounded5()); setBesucher(0); setBeos([currentUserBeo]); setObjekte([]); setBemerkungen(''); setSonderName(''); setSpende(''); setSpendeBetragRaw(''); setBesucher(''); } }, [editEntry, currentUserBeo]); useEffect(() => { if (editEntry && editEntry.BEOs) { fetch('/api/beos') .then((r) => r.json()) .then((all: BeoOption[]) => { const kuerzel = editEntry.BEOs.split(', ').map((k) => k.trim()); setBeos(all.filter((b) => kuerzel.includes(b.Kuerzel))); }) .catch(() => {}); } if (editEntry && editEntry.Objekte) { const allNames = editEntry.Objekte.split(', ').map((n) => n.trim()); const isSonneEntry = editEntry.ArtFuehrung === SONNE_ART; const names = isSonneEntry ? allNames.filter((n) => n.toLowerCase() !== 'sonne') : allNames; const kat = isSonneEntry ? 'sonne' : 'stern'; fetch('/api/objekte?kategorie=' + kat) .then((r) => r.json()) .then((all: { ID: number; Name: string }[]) => { const result: SelectedObjekt[] = names.map((name) => { const found = all.find((o) => o.Name === name); return { ID: found?.ID ?? null, Name: name }; }); setObjekte(result); }) .catch(() => {}); } }, [editEntry]); // Beim Wechsel der Art der Führung nur das zurücksetzen, was zur neuen Art nicht // mehr passt. Der erste Lauf bleibt außen vor: dort steht noch der Startwert 'RF', // während die Vorbelegung aus editEntry gerade erst gesetzt wird. useEffect(() => { const vorherigeArt = prevArtRef.current; prevArtRef.current = artFuehrung; if (vorherigeArt === null) return; // Objekte nur leeren, wenn die Kategorie wechselt (Sonne ↔ Sterne) — sonst gingen // bereits eingetragene Objekte verloren, wenn die Art erst danach gewählt wird. if ((vorherigeArt === SONNE_ART) !== (artFuehrung === SONNE_ART)) { setObjekte([]); } if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) { setBesucher(''); } if (artFuehrung !== 'SF') { setSpende(''); setSpendeBetragRaw(''); } }, [artFuehrung]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); setSuccess(false); if (showBesucher && artFuehrung !== 'Beob' && besucher === '') { setError('Bitte Besucherzahl eingeben.'); return; } if (!beginn.slice(11, 16)) { setError('Bitte Startzeit eingeben.'); return; } if (beginn === ende) { setError('Start- und Endzeit sind identisch.'); return; } const spendeBetrag = parseFloat(spendeBetragRaw.replace(',', '.')); if (artFuehrung === 'SF' && spende === '') { setError('Bitte Spende auswählen.'); return; } if (artFuehrung === 'SF' && spende === 'bar' && !(spendeBetrag > 0)) { setError('Bitte Spendenbetrag eingeben.'); return; } setSaving(true); const body = { Kuppel: kuppel, ArtFuehrung: artFuehrung, SonderName: artFuehrung === 'SF' ? sonderName : null, Spende: artFuehrung === 'SF' && spende !== '' ? spende : null, SpendeBetrag: artFuehrung === 'SF' && spende === 'bar' ? spendeBetrag : null, Beginn: beginn, Ende: ende, Besucher: besucher === '' ? 0 : besucher, beoIds: beos.map((b) => b.ID), objekte: showObjekte ? (isSonne ? [{ ID: null, Name: 'Sonne' }, ...objekte] : objekte) : [], Bemerkungen: bemerkungen, Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 }, }; const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch'; const method = editEntry ? 'PUT' : 'POST'; try { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); const daten: { sofue?: SofueStatus } = await res.json().catch(() => ({})); setSuccess(true); setTimeout(() => setSuccess(false), 5000); if (!editEntry) { setBeginn(todayDate()); setEnde(todayDate() + 'T' + nowRounded5()); setBesucher(0); setBeos([currentUserBeo]); setObjekte([]); setBemerkungen(''); setSonderName(''); setSpende(''); setSpendeBetragRaw(''); } // Die Meldung zur Sonderführung gehört nach oben: beim Bearbeiten wird dieses // Formular durch onSaved() neu gemountet und verlöre eigenen State sofort. onSaved(daten.sofue ?? null); } catch { setError('Fehler beim Speichern. Bitte erneut versuchen.'); } finally { setSaving(false); } } const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none'; const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5'; return (
{ if (e.key !== 'Enter') return; const tag = (e.target as HTMLElement).tagName; if (tag === 'BUTTON' || tag === 'TEXTAREA') return; e.preventDefault(); focusNext(e.target as HTMLElement); }} className="space-y-2 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-3 bg-white" > {/* Art der Führung / Datum / Startzeit / Endzeit / Besucher — eine Zeile */}
({ value: a, label: artLabel(a) }))} onChange={(v) => setArtFuehrung(v as ArtFuehrung)} />
{ if (!e.target.value) return; // Das Feld bezeichnet den Beginn; das Ende folgt daraus. const neuerBeginn = e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'); setBeginn(neuerBeginn); setEnde(endeAusBeginn(neuerBeginn, ende.slice(11, 16) || '00:00')); }} required className="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" />
setBeginn(beginnAusEnde(ende, t))} autoFocus className="w-24" />
setEnde(endeAusBeginn(beginn, t))} className="w-24" />
{showBesucher && (
setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)} min={0} max={9999} className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none" />
)}
{/* Erklärt den Sprung des Datumsfeldes bei Führungen über Mitternacht */} {ueberMitternacht && (
Führung über Mitternacht — endet am {datumKurz(ende)} um {ende.slice(11, 16)} Uhr.
)} {dauerZuLang && (
Dauer {dauerText} Std. — bitte Start- und Endzeit prüfen.
)} {/* Name/Gruppe und Spende bei Sonderführung */} {artFuehrung === 'SF' && (
setSonderName(e.target.value.slice(0, 200))} maxLength={200} placeholder="Name oder Gruppe der Sonderführung" className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none" />
({ value: s, label: spendeLabel(s) }))} onChange={(v) => setSpende(v as SpendeArt)} />
{spende === 'bar' && (
setSpendeBetragRaw(e.target.value)} className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none" />
)}
)} {/* BEOs */}
{/* Objekte — abhängig von der Art der Führung */} {showObjekte && (
)} {/* Bemerkungen */}