feat: Spende-Feld bei Sonderführungen — Version 1.12.0

Neue Spalten Spende (bar/ueberw/kasse/keine) und SpendeBetrag in der
Tabelle logbuch. Im Formular erscheint die Auswahl nur bei Art 'SF',
rechts neben Name/Gruppe; bei 'bar' zusätzlich ein Pflichtfeld für den
Betrag. Anzeige in der Liste als Unterzeile unter der Art.

Migration migrate_spende.sql muss vor dem Deploy manuell eingespielt
werden; die zugehörige SQL-Änderung liegt in DB4js_all.php im
sternwarte-Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 09:38:53 +02:00
parent 48aaf82899
commit cfa1852d5e
11 changed files with 113 additions and 19 deletions
+67 -15
View File
@@ -1,8 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN, ARTEN_MAP, artLabel } from '@/types/logbuch';
import type { Kuppel, ArtFuehrung, SpendeArt, BeoOption, SelectedObjekt, 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';
@@ -46,6 +46,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
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);
@@ -55,12 +57,13 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
const editEntryRef = useRef(editEntry);
editEntryRef.current = editEntry;
const formRef = useRef<HTMLFormElement>(null);
const prevArtRef = useRef<ArtFuehrung | 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'
'input:not([disabled]), textarea:not([disabled]), #art-select, #spende-select'
)
);
const idx = fields.indexOf(current);
@@ -91,6 +94,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
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) });
@@ -105,6 +110,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setObjekte([]);
setBemerkungen('');
setSonderName('');
setSpende('');
setSpendeBetragRaw('');
setBesucher('');
}
}, [editEntry, currentUserBeo]);
@@ -137,12 +144,20 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
}
}, [editEntry]);
// Objekte und Besucher zurücksetzen beim Wechsel der Art der Führung
// 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) {
@@ -162,6 +177,15 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
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);
@@ -169,6 +193,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
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,
@@ -197,6 +223,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBeos([currentUserBeo]);
setObjekte([]);
setBemerkungen('');
setSonderName('');
setSpende('');
setSpendeBetragRaw('');
}
onSaved();
} catch {
@@ -280,18 +309,41 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
)}
</div>
{/* Name/Gruppe bei Sonderführung */}
{/* Name/Gruppe und Spende bei Sonderführung */}
{artFuehrung === 'SF' && (
<div>
<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 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>
)}
+8
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import { spendeLabel } from '@/types/logbuch';
interface Props {
kuppel: Kuppel;
@@ -255,6 +256,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKue
<td className={cell}>
<div>{e.ArtFuehrung}</div>
{e.SonderName && <div className="text-xs text-gray-500">{e.SonderName}</div>}
{e.Spende && (
<div className="text-xs text-gray-500">
Spende: {spendeLabel(e.Spende)}
{e.Spende === 'bar' && e.SpendeBetrag != null &&
' ' + Number(e.SpendeBetrag).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
</div>
)}
</td>
<td className={`${cell} text-center`}>{e.Besucher || ''}</td>
<td className={cell}>