Files
logbuch/components/LogbuchForm.tsx
T
admin 5e37a9427c feat: Wetter zum gewählten Datum und zur Endzeit übernehmen — Version 1.15.0
Bisher stand im Formular immer der aktuelle Messwert, auch bei nachträglich
erfassten Einträgen. Ändert der Benutzer nun Datum oder Endzeit, werden
Temperatur, Feuchte und Luftdruck auf die Messung zu diesem Zeitpunkt
umgestellt.

/api/wetter nimmt dafür optional ?zeit=YYYY-MM-DDTHH:MM, holt über /range das
Fenster ±20 Minuten und liefert die nächstgelegene Messung. Wichtig: die
Wetter-API deutet Parameter ohne Zeitzone als UTC — die Route rechnet deshalb
Europe/Berlin um, sonst läge das Ergebnis im Sommer zwei Stunden daneben.

Gibt es keine Messung (Zukunft, Ausfall der Station), bleiben die bisherigen
Werte stehen und es erscheint ein Hinweis unter den Feldern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:11:17 +02:00

501 lines
18 KiB
TypeScript

'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)}`;
}
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<ArtFuehrung>('RF');
const [beginn, setBeginn] = useState(todayDate());
const [ende, setEnde] = useState(todayDate() + 'T' + nowRounded5());
const [besucher, setBesucher] = useState<number | ''>('');
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState('');
const [sonderName, setSonderName] = useState('');
const [spende, setSpende] = useState<SpendeArt | ''>('');
const [spendeBetragRaw, setSpendeBetragRaw] = useState('');
const [wetter, setWetter] = useState<Wetter>({ 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<HTMLFormElement>(null);
const prevArtRef = useRef<ArtFuehrung | null>(null);
const prevEndeRef = useRef<string | null>(null);
function focusNext(current: HTMLElement) {
if (!formRef.current) return;
const fields = Array.from(
formRef.current.querySelectorAll<HTMLElement>(
'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 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<Wetter>;
})
.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]);
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]);
// Objekte, Besucher und Spende zurücksetzen beim Wechsel der Art der Führung
useEffect(() => {
const vorherigeArt = prevArtRef.current;
prevArtRef.current = artFuehrung;
setObjekte([]);
if (NO_OBJEKTE_ARTEN.includes(artFuehrung)) {
setBesucher('');
}
// Nicht beim ersten Lauf leeren — sonst überschreibt der Start-Wert 'RF'
// die aus editEntry vorbelegte Spende.
if (vorherigeArt !== null && 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 (
<form
ref={formRef}
onSubmit={handleSubmit}
onKeyDown={(e) => {
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 */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[160px]">
<label className={labelCls}>Art der Führung</label>
<CustomSelect
id="art-select"
placeholder={artLabel(artFuehrung)}
options={ARTEN.map((a) => ({ value: a, label: artLabel(a) }))}
onChange={(v) => setArtFuehrung(v as ArtFuehrung)}
/>
</div>
<div className="shrink-0">
<label className={labelCls}>Datum</label>
<input
type="date"
value={beginn.slice(0, 10)}
onChange={(e) => {
if (!e.target.value) return;
setBeginn(e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'));
setEnde(e.target.value + 'T' + (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"
/>
</div>
<div className="shrink-0">
<label className={labelCls}>Startzeit</label>
<TimeInput
value={beginn.slice(11, 16)}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
autoFocus
className="w-24"
/>
</div>
<div className="shrink-0">
<label className={labelCls}>Endzeit</label>
<TimeInput
value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
className="w-24"
/>
</div>
{showBesucher && (
<div className="shrink-0">
<label className={labelCls}>Besucher</label>
<input
type="number"
value={besucher}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* Name/Gruppe und Spende bei Sonderführung */}
{artFuehrung === 'SF' && (
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[160px]">
<label className={labelCls}>Name / Gruppe</label>
<input
type="text"
value={sonderName}
onChange={(e) => 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"
/>
</div>
<div className="shrink-0 w-32">
<label className={labelCls}>Spende</label>
<CustomSelect
id="spende-select"
placeholder={spende === '' ? 'bitte wählen' : spendeLabel(spende)}
options={SPENDE_ARTEN.map((s) => ({ value: s, label: spendeLabel(s) }))}
onChange={(v) => setSpende(v as SpendeArt)}
/>
</div>
{spende === 'bar' && (
<div className="shrink-0">
<label className={labelCls}>Betrag ()</label>
<input
type="text"
inputMode="decimal"
value={spendeBetragRaw}
onChange={(e) => 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"
/>
</div>
)}
</div>
)}
{/* BEOs */}
<div>
<label className={labelCls}>BEOs</label>
<BeoSelector selected={beos} onChange={setBeos} />
</div>
{/* Objekte — abhängig von der Art der Führung */}
{showObjekte && (
<div>
<label className={labelCls}>Beobachtete Objekte</label>
<ObjektSelector
selected={objekte}
onChange={setObjekte}
kategorie={isSonne ? 'sonne' : 'stern'}
fixedItems={isSonne ? [{ ID: null, Name: 'Sonne' }] : []}
/>
</div>
)}
{/* Bemerkungen */}
<div>
<label className={labelCls}>
Bemerkungen
<span className="ml-2 text-gray-400 font-normal text-xs">({bemerkungen.length}/500)</span>
</label>
<textarea
value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={2}
className="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 resize-y"
placeholder="Freier Text (max. 500 Zeichen)"
/>
</div>
{/* Wetter */}
<div>
<div className="grid grid-cols-3">
<div>
<label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label>
<input
type="text"
inputMode="decimal"
value={tempRaw}
onChange={(e) => setTempRaw(e.target.value)}
className="w-1/3 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"
/>
</div>
<div className="flex flex-col items-center">
<label className="block text-xs text-gray-500 mb-0.5">Feuchte (%)</label>
<input
type="number"
value={wetter.feuchte}
onChange={(e) => setWetter({ ...wetter, feuchte: parseInt(e.target.value) || 0 })}
step="1"
className="w-1/3 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"
/>
</div>
<div className="flex flex-col items-end">
<label className="block text-xs text-gray-500 mb-0.5">Luftdruck (hPa)</label>
<input
type="number"
value={wetter.druck}
onChange={(e) => setWetter({ ...wetter, druck: parseInt(e.target.value) || 0 })}
step="1"
className="w-1/3 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"
/>
</div>
</div>
{wetterHinweis && (
<div className="mt-1 text-xs text-amber-700">{wetterHinweis}</div>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
{success && !editEntry && (
<div className="bg-green-50 border border-green-300 text-green-700 px-3 py-2 rounded-lg text-sm">
Eintrag gespeichert.
</div>
)}
{/* Buttons — zentriert */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
type="submit"
disabled={saving}
className="w-full sm:w-auto px-6 py-1 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
>
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
</button>
{editEntry && (
<button
type="button"
onClick={() => onSaved()}
className="w-full sm:w-auto px-6 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
>
Abbrechen
</button>
)}
</div>
</form>
);
}