feat: Sonderführungen automatisch im Register SoFue2 nachtragen — Version 1.13.0

Beim Speichern eines Logbuch-Eintrags der Art 'SF' wird der passende
Datensatz in SoFue2 über das Datum gesucht und dort stattgefunden=1,
anzahl_echt, bezahlt und remarks gesetzt. Gibt es an dem Tag mehrere
Führungen, gewinnt die zeitlich nächstgelegene.

Das Ergebnis kommt als Feld 'sofue' aus der PHP-Bridge zurück und wird
über dem Formular angezeigt — grün bei Erfolg, gelb, wenn keine Führung
gefunden wurde. Der Logbuch-Eintrag wird in dem Fall trotzdem gespeichert.
Die Meldung liegt in MainClient, weil LogbuchForm beim Bearbeiten durch
onSaved() neu gemountet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:57:22 +02:00
parent cfa1852d5e
commit b5b75e0d77
10 changed files with 78 additions and 13 deletions
+8
View File
@@ -80,6 +80,14 @@ 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`). **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. **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: **Beobachtete Objekte** nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:
+2
View File
@@ -35,6 +35,8 @@ Next.js 16 App Router application. All pages are server components; interactive
`ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object. `SF` (Sonderführung) additionally shows `SonderName` plus the mandatory `Spende` select (`bar`, `ueberw`, `kasse`, `keine` — labels in `SPENDE_MAP`); `SpendeBetrag` is only filled for `bar`. Both columns stay `NULL` for every other Art, enforced client-side in `LogbuchForm` and server-side in `spendeValues()` in `DB4js_all.php`. `ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object. `SF` (Sonderführung) additionally shows `SonderName` plus the mandatory `Spende` select (`bar`, `ueberw`, `kasse`, `keine` — labels in `SPENDE_MAP`); `SpendeBetrag` is only filled for `bar`. Both columns stay `NULL` for every other Art, enforced client-side in `LogbuchForm` and server-side in `spendeValues()` in `DB4js_all.php`.
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.
## Deployment ## 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. `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.
+42 -2
View File
@@ -2,13 +2,31 @@
import { useState } from 'react'; import { useState } from 'react';
import { KUPPELN } from '@/types/logbuch'; import { KUPPELN } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, LogbuchEintrag, SofueStatus } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm'; import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList'; import LogbuchList from '@/components/LogbuchList';
import Statistik from '@/components/Statistik'; import Statistik from '@/components/Statistik';
import Fahrkosten from '@/components/Fahrkosten'; import Fahrkosten from '@/components/Fahrkosten';
import packageJson from '@/package.json'; import packageJson from '@/package.json';
function datumDe(iso?: string): string {
if (!iso) return '';
const [j, m, t] = iso.slice(0, 10).split('-');
return t && m && j ? `${t}.${m}.${j}` : iso;
}
/** Rückmeldung zur Rückschreibung einer Sonderführung ins Register SoFue2. */
function sofueMeldung(s: SofueStatus): string {
switch (s.status) {
case 'ok':
return `Sonderführung vom ${datumDe(s.wtermin ?? s.datum)} im Register als stattgefunden eingetragen.`;
case 'kein_termin':
return `Keine Sonderführung am ${datumDe(s.datum)} gefunden — das Sonderführungs-Register wurde nicht aktualisiert.`;
default:
return 'Das Sonderführungs-Register konnte nicht aktualisiert werden. Bitte den Eintrag dort von Hand nachtragen.';
}
}
interface Props { interface Props {
kuerzel: string; kuerzel: string;
beoId: number; beoId: number;
@@ -22,6 +40,7 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null); const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle'); const [backupState, setBackupState] = useState<'idle' | 'running' | 'ok' | 'error'>('idle');
const [sofueHinweis, setSofueHinweis] = useState<SofueStatus | null>(null);
const grafikSrc = '/api/statistik/grafik'; const grafikSrc = '/api/statistik/grafik';
@@ -32,9 +51,10 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName }; const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName };
function handleSaved() { function handleSaved(sofue?: SofueStatus | null) {
setRefreshKey((k) => k + 1); setRefreshKey((k) => k + 1);
setEditEntry(null); setEditEntry(null);
setSofueHinweis(sofue ?? null);
} }
function handleEdit(entry: LogbuchEintrag) { function handleEdit(entry: LogbuchEintrag) {
@@ -148,6 +168,26 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
Eintrag bearbeiten (ID {editEntry.ID}) Eintrag bearbeiten (ID {editEntry.ID})
</div> </div>
)} )}
{sofueHinweis && sofueHinweis.status !== 'uebersprungen' && (
<div
className={
'mb-3 text-sm rounded-lg px-3 py-2 border flex justify-between items-start gap-3 ' +
(sofueHinweis.status === 'ok'
? 'text-green-700 bg-green-50 border-green-300'
: 'text-amber-800 bg-amber-50 border-amber-400')
}
>
<span>{sofueMeldung(sofueHinweis)}</span>
<button
type="button"
onClick={() => setSofueHinweis(null)}
className="shrink-0 font-medium hover:opacity-70"
aria-label="Meldung schließen"
>
</button>
</div>
)}
<LogbuchForm <LogbuchForm
key={`${activeKuppel}-${editEntry?.ID ?? 'new'}`} key={`${activeKuppel}-${editEntry?.ID ?? 'new'}`}
kuppel={activeKuppel} kuppel={activeKuppel}
+2 -2
View File
@@ -14,7 +14,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, SonderName, Spende, SpendeBetrag, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Spende, SpendeBetrag, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', { const result = await phpdb.updateLogbuch(logbuchId, session.beoId, session.role ?? '', {
Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Kuppel, ArtFuehrung, SonderName, Beginn, Ende,
Spende: Spende ?? null, Spende: Spende ?? null,
SpendeBetrag: SpendeBetrag ?? null, SpendeBetrag: SpendeBetrag ?? null,
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}); });
triggerBackup(); triggerBackup();
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true, sofue: result?.sofue });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 }); if (error.message.includes('404')) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 });
+1
View File
@@ -45,6 +45,7 @@ export async function POST(request: NextRequest) {
}); });
triggerBackup(); triggerBackup();
// result enthält id und — bei Sonderführungen — den sofue-Status der Rückschreibung
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/logbuch:', error); console.error('POST /api/logbuch:', error);
+8 -4
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Kuppel, ArtFuehrung, SpendeArt, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch'; 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 { ARTEN, ARTEN_MAP, artLabel, SPENDE_ARTEN, spendeLabel } from '@/types/logbuch';
import BeoSelector from './BeoSelector'; import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector'; import ObjektSelector from './ObjektSelector';
@@ -12,7 +12,8 @@ interface Props {
kuppel: Kuppel; kuppel: Kuppel;
currentUserBeo: BeoOption; currentUserBeo: BeoOption;
editEntry?: LogbuchEintrag | null; editEntry?: LogbuchEintrag | null;
onSaved: () => void; /** sofue: Ergebnis der Rückschreibung ins Sonderführungs-Register, sofern erfolgt. */
onSaved: (sofue?: SofueStatus | null) => void;
} }
function toLocalDatetimeValue(isoOrDatetime: string): string { function toLocalDatetimeValue(isoOrDatetime: string): string {
@@ -214,6 +215,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const daten: { sofue?: SofueStatus } = await res.json().catch(() => ({}));
setSuccess(true); setSuccess(true);
setTimeout(() => setSuccess(false), 5000); setTimeout(() => setSuccess(false), 5000);
if (!editEntry) { if (!editEntry) {
@@ -227,7 +229,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setSpende(''); setSpende('');
setSpendeBetragRaw(''); setSpendeBetragRaw('');
} }
onSaved(); // 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 { } catch {
setError('Fehler beim Speichern. Bitte erneut versuchen.'); setError('Fehler beim Speichern. Bitte erneut versuchen.');
} finally { } finally {
@@ -440,7 +444,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
{editEntry && ( {editEntry && (
<button <button
type="button" type="button"
onClick={onSaved} 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" 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 Abbrechen
+4 -4
View File
@@ -1,4 +1,4 @@
import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, Wetter } from '@/types/logbuch'; import type { BeoOption, LogbuchEintrag, ObjektOption, SelectedObjekt, SofueStatus, Wetter } from '@/types/logbuch';
const PHP_DB_URL = process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php'; const PHP_DB_URL = process.env.PHP_DB_URL ?? 'http://localhost:8080/DB4js_all.php';
@@ -97,7 +97,7 @@ export interface CreateLogbuchData {
created_by: number; created_by: number;
} }
export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number }> { export async function createLogbuch(data: CreateLogbuchData): Promise<{ id: number; sofue?: SofueStatus }> {
return call('LB_CREATE_LOGBUCH', data); return call('LB_CREATE_LOGBUCH', data);
} }
@@ -106,8 +106,8 @@ export async function updateLogbuch(
userId: number, userId: number,
userRole: string, userRole: string,
data: Omit<CreateLogbuchData, 'created_by'> data: Omit<CreateLogbuchData, 'created_by'>
): Promise<void> { ): Promise<{ sofue?: SofueStatus }> {
await call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data }); return call('LB_UPDATE_LOGBUCH', { id, user_id: userId, user_role: userRole, ...data });
} }
export async function deleteLogbuch( export async function deleteLogbuch(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.12.0", "version": "1.13.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+2
View File
@@ -242,6 +242,8 @@
<p><strong>Spende</strong> erscheint nur bei Sonderführung, rechts neben Name / Gruppe. Auswahl zwischen <code>bar</code>, <code>überw.</code>, <code>kasse</code> und <code>keine</code>; die Angabe ist bei Sonderführungen verpflichtend. Bei <code>bar</code> erscheint zusätzlich ein Feld <strong>Betrag (€)</strong>, in das der erhaltene Betrag eingetragen werden muss (Komma oder Punkt als Dezimaltrennzeichen, z. B. <code>25,50</code>).</p> <p><strong>Spende</strong> erscheint nur bei Sonderführung, rechts neben Name / Gruppe. Auswahl zwischen <code>bar</code>, <code>überw.</code>, <code>kasse</code> und <code>keine</code>; die Angabe ist bei Sonderführungen verpflichtend. Bei <code>bar</code> erscheint zusätzlich ein Feld <strong>Betrag (€)</strong>, in das der erhaltene Betrag eingetragen werden muss (Komma oder Punkt als Dezimaltrennzeichen, z. B. <code>25,50</code>).</p>
<p><strong>Sonderführungen werden automatisch nachgetragen.</strong> Beim Speichern eines Eintrags der Art Sonderführung wird die passende Führung im Sonderführungs-Register über das Datum gesucht und dort als <em>stattgefunden</em> 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.</p>
<p><strong>BEOs</strong> 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.</p> <p><strong>BEOs</strong> 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.</p>
<p><strong>Beobachtete Objekte</strong> nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:</p> <p><strong>Beobachtete Objekte</strong> nicht sichtbar bei BEOS und TD; bei Sonnenführungen fest auf „Sonne" gesetzt. Für alle anderen Arten:</p>
+8
View File
@@ -36,6 +36,14 @@ export function spendeLabel(s: SpendeArt): string {
return SPENDE_MAP[s] || s; return SPENDE_MAP[s] || s;
} }
/** Ergebnis der Rückschreibung einer Sonderführung ins Register SoFue2. */
export interface SofueStatus {
status: 'ok' | 'kein_termin' | 'fehler' | 'uebersprungen';
id?: number;
wtermin?: string;
datum?: string;
}
export interface BeoOption { export interface BeoOption {
ID: number; ID: number;
Kuerzel: string; Kuerzel: string;